Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89986d5d17 | |||
| ae8d055689 | |||
| 1607f80a0a | |||
| a0c20e209c | |||
| 648477931b | |||
| e42e8f66f2 | |||
| de5358727a | |||
| 706459783f | |||
| 1ca9a679cf | |||
| 920f88aee4 | |||
| cef3ae5bdf | |||
| 39c788e576 | |||
| d40713ac99 | |||
| f53bed7190 | |||
| 0b9c415681 | |||
| 38003c59e0 | |||
| f1119d8c30 | |||
| a5e2b4d4f0 | |||
| a12cba030d | |||
| 56d1140aad | |||
| 0379141304 | |||
| b6e9f7157d | |||
| edb3df719f | |||
| 9bde3df74d | |||
| 2426e6fe3e | |||
| 93209abebd | |||
| 158786ccd5 | |||
| 77be5624f2 | |||
| 4b580c90ef |
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
name: Issue report
|
||||
description: Report an issue in LNReader
|
||||
labels: [Bug]
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
@@ -45,7 +45,7 @@ body:
|
||||
label: LNReader version
|
||||
description: You can find your LNReader version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "2.1.3"
|
||||
Example: "2.1.4"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -88,7 +88,7 @@ body:
|
||||
required: true
|
||||
- label: If this is an issue with a plugin, I should be opening an issue in the [plugins repository](https://github.com/lnreader/lnreader-plugins/issues/new/choose).
|
||||
required: true
|
||||
- label: I have updated the app to version **[2.1.3](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
- label: I have updated the app to version **[2.1.4](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
name: Feature request
|
||||
description: Suggest a feature to improve LNReader
|
||||
labels: [Feature Request]
|
||||
labels: [feature]
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
@@ -34,7 +34,7 @@ body:
|
||||
required: true
|
||||
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/lnreader/lnreader-sources/issues/new/choose).
|
||||
required: true
|
||||
- label: I have updated the app to version **[2.1.3](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
- label: I have updated the app to version **[2.1.4](https://github.com/lnreader/lnreader/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
@@ -114,3 +114,6 @@ release-artifacts/
|
||||
!plugins/android/
|
||||
ios/
|
||||
!modules/**/ios/
|
||||
|
||||
# iOS: reader assets inlined into the JS bundle (scripts/embed-reader-assets.mjs)
|
||||
src/utils/readerAssets/embedded.generated.ts
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import AppErrorBoundary, {
|
||||
ErrorFallback,
|
||||
} from '@components/AppErrorBoundary/AppErrorBoundary';
|
||||
import CrashRecoveryGate from '@components/AppErrorBoundary/CrashRecoveryGate';
|
||||
|
||||
import Main from './src/navigators/Main';
|
||||
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
|
||||
@@ -64,7 +65,7 @@ const ThemedPaperProvider = ({ children }: PropsWithChildren) => {
|
||||
return <PaperProvider theme={paperTheme}>{children}</PaperProvider>;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const Application = () => {
|
||||
useRozeniteSqlitePlugin({ adapters: sqliteAdapters });
|
||||
const { success: databaseReady, error: databaseError } = useInitDatabase();
|
||||
const { ready: servicesReady, error: servicesError } =
|
||||
@@ -80,9 +81,7 @@ const App = () => {
|
||||
|
||||
if (initializationError) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ErrorFallback error={initializationError} resetError={() => null} />
|
||||
</ThemeProvider>
|
||||
<ErrorFallback error={initializationError} resetError={() => null} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,30 +90,33 @@ const App = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider>
|
||||
<ThemedRootView>
|
||||
<KeyboardProvider>
|
||||
<AppErrorBoundary>
|
||||
<SafeAreaProvider>
|
||||
<ThemedPaperProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<StatusBar
|
||||
translucent={true}
|
||||
backgroundColor="transparent"
|
||||
/>
|
||||
<Main />
|
||||
</BottomSheetModalProvider>
|
||||
</ThemedPaperProvider>
|
||||
</SafeAreaProvider>
|
||||
</AppErrorBoundary>
|
||||
</KeyboardProvider>
|
||||
</ThemedRootView>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
<ThemedRootView>
|
||||
<KeyboardProvider>
|
||||
<AppErrorBoundary>
|
||||
<SafeAreaProvider>
|
||||
<ThemedPaperProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<StatusBar translucent={true} backgroundColor="transparent" />
|
||||
<Main />
|
||||
</BottomSheetModalProvider>
|
||||
</ThemedPaperProvider>
|
||||
</SafeAreaProvider>
|
||||
</AppErrorBoundary>
|
||||
</KeyboardProvider>
|
||||
</ThemedRootView>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider>
|
||||
<CrashRecoveryGate>
|
||||
<Application />
|
||||
</CrashRecoveryGate>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> **open-lab fork:** branch `ios` adds an iOS build (sideloaded with SideStore and a free Apple ID) and preconfigures the open-lab plugin repository. See [docs/ios-sidestore.md](docs/ios-sidestore.md).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://lnreader.app">
|
||||
<img src="./.github/readme-images/icon_new.png" align="center" width="128" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"expo": {
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.4",
|
||||
"name": "LNReader",
|
||||
"slug": "LNReader",
|
||||
"scheme": "lnreader",
|
||||
@@ -51,12 +51,15 @@
|
||||
},
|
||||
"enableMinifyInReleaseBuilds": true,
|
||||
"enableShrinkResourcesInReleaseBuilds": true
|
||||
},
|
||||
"ios": {
|
||||
"deploymentTarget": "16.4"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"android": {
|
||||
"versionCode": 4196355,
|
||||
"versionCode": 4196356,
|
||||
"package": "com.rajarsheechatterjee.LNReader",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/native/ic_launcher_foreground.png",
|
||||
@@ -70,6 +73,19 @@
|
||||
"android.permission.FOREGROUND_SERVICE_DATA_SYNC",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.POST_NOTIFICATIONS"
|
||||
],
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "SEND",
|
||||
"category": [
|
||||
"DEFAULT"
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"mimeType": "text/plain"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
@@ -78,9 +94,18 @@
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.rajarsheechatterjee.LNReader",
|
||||
"bundleIdentifier": "br.dev.marcospaulo.lnreader",
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["audio"]
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
],
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"UIFileSharingEnabled": true,
|
||||
"LSSupportsOpeningDocumentsInPlace": true,
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,41 @@ html {
|
||||
background-color: var(--theme-secondary);
|
||||
}
|
||||
|
||||
#text-selection-ui {
|
||||
position: fixed;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--theme-outlineVariant);
|
||||
border-radius: 24px;
|
||||
background-color: var(--theme-surface-0-9);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#text-selection-ui button {
|
||||
min-width: 88px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-onSurface);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
#text-selection-ui button + button {
|
||||
border-inline-start: 1px solid var(--theme-outlineVariant);
|
||||
}
|
||||
|
||||
#text-selection-ui button:active {
|
||||
background-color: var(--theme-surfaceVariant);
|
||||
}
|
||||
|
||||
body {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
|
||||
@@ -15,6 +15,12 @@ body.page-reader > #LNReader-chapter {
|
||||
column-fill: auto;
|
||||
transition: transform 200ms;
|
||||
}
|
||||
body.page-reader.no-animation > #LNReader-chapter {
|
||||
transition: none !important;
|
||||
}
|
||||
body.page-reader.no-animation .transition-chapter {
|
||||
transition: none !important;
|
||||
}
|
||||
.transition-chapter {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
|
||||
+55
-14
@@ -211,10 +211,22 @@ window.tts = new (function () {
|
||||
|
||||
this.normalizeText = text => {
|
||||
if (!text) return '';
|
||||
return text
|
||||
const normalized = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
|
||||
.replace(/\s*([.,!?;:])\s*/g, '$1 ')
|
||||
.trim();
|
||||
|
||||
const dashOnlyText = normalized.replace(/\s/g, '');
|
||||
if (
|
||||
dashOnlyText.length >= 3 &&
|
||||
/^[\-‐‑‒–—―−⁓⸺⸻﹘﹣-]+$/u.test(dashOnlyText)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// if can find a readable node, else stop tts
|
||||
@@ -512,7 +524,8 @@ van.derive(() => {
|
||||
}
|
||||
});
|
||||
|
||||
window.pageReader = new (function () {
|
||||
var pageReader;
|
||||
window.pageReader = pageReader = new (function () {
|
||||
const config =
|
||||
typeof initialPageReaderConfig === 'undefined'
|
||||
? {}
|
||||
@@ -532,18 +545,21 @@ window.pageReader = new (function () {
|
||||
document.getElementsByClassName('transition-chapter')[0];
|
||||
if (!this.chapterEnding) return;
|
||||
}
|
||||
const noAnim = Boolean(
|
||||
reader.generalSettings?.val?.pageReaderDisableAnimation,
|
||||
);
|
||||
this.chapterEnding.style.transition = 'unset';
|
||||
if (bool) {
|
||||
this.chapterEnding.style.transform = `translateX(${left ? -200 : 0}vw)`;
|
||||
requestAnimationFrame(() => {
|
||||
if (!instant) {
|
||||
if (!instant && !noAnim) {
|
||||
this.chapterEnding.style.transition = 'transform 200ms';
|
||||
}
|
||||
this.chapterEnding.style.transform = 'translateX(-100vw)';
|
||||
});
|
||||
this.chapterEndingVisible.val = true;
|
||||
} else {
|
||||
if (!instant) {
|
||||
if (!instant && !noAnim) {
|
||||
this.chapterEnding.style.transition = 'transform 200ms';
|
||||
}
|
||||
this.chapterEnding.style.transform = `translateX(${left ? -200 : 0}vw)`;
|
||||
@@ -567,26 +583,35 @@ window.pageReader = new (function () {
|
||||
return;
|
||||
}
|
||||
destPage = parseInt(destPage, 10);
|
||||
const noAnim = Boolean(
|
||||
reader.generalSettings?.val?.pageReaderDisableAnimation,
|
||||
);
|
||||
if (destPage < 0) {
|
||||
if (!reader.prevChapter) return;
|
||||
document.getElementsByClassName('transition-chapter')[0].innerText =
|
||||
reader.prevChapter.name;
|
||||
this.showChapterEnding(true, false, true);
|
||||
this.showChapterEnding(true, noAnim, true);
|
||||
this.chapterNavigationPending = true;
|
||||
setTimeout(() => {
|
||||
reader.post({ type: 'prev' });
|
||||
}, 200);
|
||||
setTimeout(
|
||||
() => {
|
||||
reader.post({ type: 'prev' });
|
||||
},
|
||||
noAnim ? 0 : 200,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (destPage >= this.totalPages.val) {
|
||||
if (!reader.nextChapter) return;
|
||||
document.getElementsByClassName('transition-chapter')[0].innerText =
|
||||
reader.nextChapter.name;
|
||||
this.showChapterEnding(true);
|
||||
this.showChapterEnding(true, noAnim);
|
||||
this.chapterNavigationPending = true;
|
||||
setTimeout(() => {
|
||||
reader.post({ type: 'next' });
|
||||
}, 200);
|
||||
setTimeout(
|
||||
() => {
|
||||
reader.post({ type: 'next' });
|
||||
},
|
||||
noAnim ? 0 : 200,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.page.val = destPage;
|
||||
@@ -594,7 +619,7 @@ window.pageReader = new (function () {
|
||||
'translateX(-' + destPage * 100 + '%)';
|
||||
|
||||
const newProgress = parseInt(
|
||||
((pageReader.page.val + 1) / pageReader.totalPages.val) * 100,
|
||||
((this.page.val + 1) / this.totalPages.val) * 100,
|
||||
10,
|
||||
);
|
||||
|
||||
@@ -675,6 +700,18 @@ window.pageReader = new (function () {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
van.derive(() => {
|
||||
document.body.classList.toggle(
|
||||
'no-animation',
|
||||
Boolean(reader.generalSettings.val.pageReaderDisableAnimation),
|
||||
);
|
||||
reader.chapterElement?.style.removeProperty('transition');
|
||||
const chapterEnding =
|
||||
this.chapterEnding ||
|
||||
document.getElementsByClassName('transition-chapter')[0];
|
||||
chapterEnding?.style.removeProperty('transition');
|
||||
});
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -893,7 +930,11 @@ window.addEventListener('load', () => {
|
||||
if (Math.abs(diffX) > 8 || Math.abs(diffY) > 8) {
|
||||
pageReader.ignoreClickUntil = Date.now() + 400;
|
||||
}
|
||||
reader.chapterElement.style.transition = 'transform 200ms';
|
||||
reader.chapterElement.style.transition = reader.generalSettings.val
|
||||
.pageReaderDisableAnimation
|
||||
? 'none'
|
||||
: 'transform 200ms';
|
||||
reader.chapterElement.style.removeProperty('transition');
|
||||
const diffXPercentage = diffX / reader.layoutWidth;
|
||||
if (diffXPercentage < -0.3) {
|
||||
pageReader.movePage(pageReader.page.val + 1);
|
||||
|
||||
Vendored
+2
@@ -29,6 +29,8 @@ export interface Reader {
|
||||
finished: string;
|
||||
nextChapter: string;
|
||||
noNextChapter: string;
|
||||
removeText: string;
|
||||
replaceText: string;
|
||||
};
|
||||
|
||||
setAdjacentChapters: (adjacent: {
|
||||
|
||||
@@ -10,28 +10,11 @@ window.textRemover = new (function () {
|
||||
selectionUI = div(
|
||||
{
|
||||
id: 'text-selection-ui',
|
||||
style: `
|
||||
position: fixed;
|
||||
background: color-mix(in srgb, var(--theme-surface), transparent 10%);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
z-index: 100000;
|
||||
opacity: 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
transition: opacity 150ms
|
||||
`,
|
||||
role: 'group',
|
||||
},
|
||||
button(
|
||||
{
|
||||
style: `
|
||||
background: var(--theme-secondary);
|
||||
color: var(--theme-onSecondary);
|
||||
padding: 6px 12px;
|
||||
margin: 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
`,
|
||||
type: 'button',
|
||||
onclick: e => {
|
||||
if (reader.hidden.val) {
|
||||
e.stopPropagation();
|
||||
@@ -39,19 +22,11 @@ window.textRemover = new (function () {
|
||||
removeSelectedText();
|
||||
},
|
||||
},
|
||||
'Remove',
|
||||
reader.strings.removeText,
|
||||
),
|
||||
button(
|
||||
{
|
||||
style: `
|
||||
background: var(--theme-secondary);
|
||||
color: var(--theme-onSecondary);
|
||||
padding: 6px 12px;
|
||||
margin: 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
`,
|
||||
type: 'button',
|
||||
onclick: e => {
|
||||
if (reader.hidden.val) {
|
||||
e.stopPropagation();
|
||||
@@ -59,7 +34,7 @@ window.textRemover = new (function () {
|
||||
replaceSelectedText();
|
||||
},
|
||||
},
|
||||
'Replace',
|
||||
reader.strings.replaceText,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -138,12 +113,14 @@ window.textRemover = new (function () {
|
||||
}
|
||||
|
||||
ui.style.opacity = '1';
|
||||
ui.style.pointerEvents = 'auto';
|
||||
isUIActive = true;
|
||||
}
|
||||
|
||||
function hideSelectionUI() {
|
||||
if (selectionUI) {
|
||||
selectionUI.style.opacity = '0';
|
||||
selectionUI.style.pointerEvents = 'none';
|
||||
}
|
||||
isUIActive = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# LNReader on iOS (SideStore, free Apple ID)
|
||||
|
||||
This fork (`open-lab/lnreader`, branch `ios`) builds for iOS. Apple only lets you
|
||||
install your own builds signed with an Apple ID; with a free one the app is valid for
|
||||
7 days and has to be refreshed. SideStore does the signing and refreshing on the
|
||||
phone itself.
|
||||
|
||||
## What is different on iOS
|
||||
|
||||
| Works | Not available on iOS |
|
||||
|---|---|
|
||||
| Browsing, plugins (the open-lab repository is preconfigured on a fresh install), reading, Custom code (the illustrator snippet), downloads, EPUB reading, TTS, local library | Downloads and library updates in the background: tasks run while the app is open and continue when you return to it. No scheduled library updates or automatic backups. |
|
||||
| Downloaded chapters with their images | Page turning with the volume buttons (iOS gives apps no volume-key events) |
|
||||
| | Toast messages, "Share to LNReader", DNS-over-HTTPS, crash-log sharing |
|
||||
| | Folder and file pickers: EPUB export, backup create and restore |
|
||||
|
||||
## 1. Build the .ipa (Mac with Xcode)
|
||||
|
||||
```bash
|
||||
git clone https://git.marcospaulo.dev.br/open-lab/lnreader.git
|
||||
cd lnreader && git switch ios
|
||||
./scripts/build-ios-unsigned.sh # -> build/LNReader-unsigned.ipa
|
||||
```
|
||||
|
||||
Needs Xcode with its command line tools, Node 22.11 or newer, pnpm and CocoaPods
|
||||
(`brew install node pnpm cocoapods`). The first build takes a while (pods).
|
||||
|
||||
Publish it so the phone can download it:
|
||||
|
||||
```bash
|
||||
tea releases create --repo open-lab/lnreader --tag ios-$(jq -r .version package.json) \
|
||||
--title "iOS $(jq -r .version package.json)" --asset build/LNReader-unsigned.ipa
|
||||
```
|
||||
|
||||
## 2. Install SideStore on the iPhone (once)
|
||||
|
||||
Follow the current guide at <https://docs.sidestore.io> (the tools change). As of 2026:
|
||||
|
||||
1. On the iPhone: Settings > Privacy & Security > **Developer Mode** on (restart when asked).
|
||||
2. Install **LocalDevVPN** from the App Store; SideStore uses it to sign apps on the phone.
|
||||
3. On a computer (macOS, Windows or Linux) run **iloader**, connect the iPhone with a
|
||||
cable, sign in with your Apple ID and install SideStore. It also creates the
|
||||
pairing file SideStore needs.
|
||||
4. Open SideStore on the phone and sign in with the same Apple ID.
|
||||
|
||||
A free Apple ID allows 3 sideloaded apps at once (SideStore is one) and 10 new app IDs
|
||||
per week.
|
||||
|
||||
## 3. Install LNReader
|
||||
|
||||
1. On the iPhone, open the release page in Safari and download `LNReader-unsigned.ipa`
|
||||
(it lands in Files > Downloads).
|
||||
2. Turn LocalDevVPN on, open SideStore > My Apps > **+**, and pick the `.ipa`.
|
||||
3. Open LNReader. Under More > Settings > Reader > Custom code > JS, paste the
|
||||
illustrator snippet (it carries your access key, so it is not built in).
|
||||
|
||||
## Every 7 days
|
||||
|
||||
With LocalDevVPN on, open SideStore and tap **Refresh All** (or let a Shortcuts
|
||||
automation do it daily). If an app expires it stops opening; refreshing brings it back
|
||||
with its data intact. Updating LNReader: build and publish a new `.ipa`, then install
|
||||
it over the old one from SideStore.
|
||||
@@ -3,6 +3,9 @@ import { registerRootComponent } from 'expo';
|
||||
import { AppRegistry, I18nManager } from 'react-native';
|
||||
import { i18n } from './src/i18n/translations';
|
||||
import { runHeadlessBackgroundTask } from './src/services/backgroundTasks';
|
||||
import { installJsCrashHandler } from './src/services/crashLogs/installJsCrashHandler';
|
||||
|
||||
installJsCrashHandler();
|
||||
|
||||
AppRegistry.registerHeadlessTask(
|
||||
'LNReaderBackgroundTask',
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ const baseTransform = {
|
||||
|
||||
const baseTransformIgnorePatterns = [
|
||||
// 'node_modules/(?!(.pnpm/|@op-engineering|drizzle-orm|lodash-es|@babel/runtime))',
|
||||
'node_modules/(?!(?:.pnpm/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|color|@op-engineering|drizzle-orm|lodash-es))',
|
||||
'node_modules/(?!(?:.pnpm/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|@material/material-color-utilities|@pchmn/expo-material3-theme|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|color|@op-engineering|drizzle-orm|lodash-es))',
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
|
||||
+68
-9
@@ -5,13 +5,41 @@ import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
data class BackgroundTaskSummary(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val state: String,
|
||||
val progress: Double?,
|
||||
val progressText: String?,
|
||||
val attempt: Int,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface BackgroundTaskDao {
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
suspend fun insert(task: BackgroundTaskEntity)
|
||||
|
||||
@Query("SELECT * FROM background_tasks ORDER BY createdAt ASC")
|
||||
suspend fun getAll(): List<BackgroundTaskEntity>
|
||||
@Query(
|
||||
"SELECT id, type, title, description, state, progress, progressText, " +
|
||||
"attempt, createdAt, updatedAt " +
|
||||
"FROM background_tasks " +
|
||||
"WHERE state IN ('queued', 'running', 'paused') ORDER BY createdAt ASC",
|
||||
)
|
||||
suspend fun getActive(): List<BackgroundTaskSummary>
|
||||
|
||||
@Query(
|
||||
"DELETE FROM background_tasks " +
|
||||
"WHERE state IN ('succeeded', 'failed', 'cancelled') " +
|
||||
"AND updatedAt < :cutoff",
|
||||
)
|
||||
suspend fun deleteOldTerminal(cutoff: Long)
|
||||
|
||||
@Query("DELETE FROM background_tasks WHERE id = :id AND state = :state")
|
||||
suspend fun deleteIfState(id: String, state: String): Int
|
||||
|
||||
@Query("SELECT * FROM background_tasks WHERE id = :id LIMIT 1")
|
||||
suspend fun get(id: String): BackgroundTaskEntity?
|
||||
@@ -19,11 +47,42 @@ interface BackgroundTaskDao {
|
||||
@Query("SELECT * FROM background_tasks WHERE type = :type AND state IN ('queued', 'running', 'paused') LIMIT 1")
|
||||
suspend fun getActiveByType(type: String): BackgroundTaskEntity?
|
||||
|
||||
@Query("UPDATE background_tasks SET state = :state, updatedAt = :updatedAt WHERE id = :id")
|
||||
suspend fun updateState(id: String, state: String, updatedAt: Long)
|
||||
@Query("SELECT * FROM background_tasks WHERE queueName = :queueName AND state = 'queued' ORDER BY createdAt ASC")
|
||||
suspend fun getQueuedByQueueName(queueName: String): List<BackgroundTaskEntity>
|
||||
|
||||
@Query(
|
||||
"UPDATE background_tasks SET state = 'paused', updatedAt = :updatedAt " +
|
||||
"WHERE id = :id AND state IN ('queued', 'running')",
|
||||
)
|
||||
suspend fun markPaused(id: String, updatedAt: Long): Int
|
||||
|
||||
@Query(
|
||||
"UPDATE background_tasks SET state = 'queued', updatedAt = :updatedAt " +
|
||||
"WHERE id = :id AND state = 'paused'",
|
||||
)
|
||||
suspend fun markQueued(id: String, updatedAt: Long): Int
|
||||
|
||||
@Query(
|
||||
"UPDATE background_tasks SET state = 'cancelled', updatedAt = :updatedAt " +
|
||||
"WHERE id = :id AND state IN ('queued', 'running', 'paused')",
|
||||
)
|
||||
suspend fun markCancelled(id: String, updatedAt: Long): Int
|
||||
|
||||
@Query("UPDATE background_tasks SET state = :state, updatedAt = :updatedAt WHERE id = :id AND state = 'running'")
|
||||
suspend fun finishRunning(id: String, state: String, updatedAt: Long)
|
||||
suspend fun finishRunning(id: String, state: String, updatedAt: Long): Int
|
||||
|
||||
@Query(
|
||||
"UPDATE background_tasks " +
|
||||
"SET state = :state, progress = :progress, progressText = :progressText, updatedAt = :updatedAt " +
|
||||
"WHERE id = :id AND state = 'running'",
|
||||
)
|
||||
suspend fun finishRunningWithProgress(
|
||||
id: String,
|
||||
state: String,
|
||||
progress: Double?,
|
||||
progressText: String?,
|
||||
updatedAt: Long,
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
@@ -48,11 +107,11 @@ interface BackgroundTaskDao {
|
||||
maxConcurrentDownloads: Int,
|
||||
): Int
|
||||
|
||||
@Query("UPDATE background_tasks SET progress = :progress, progressText = :progressText, updatedAt = :updatedAt WHERE id = :id")
|
||||
suspend fun updateProgress(id: String, progress: Double?, progressText: String?, updatedAt: Long)
|
||||
@Query("UPDATE background_tasks SET progress = :progress, progressText = :progressText, updatedAt = :updatedAt WHERE id = :id AND state = 'running'")
|
||||
suspend fun updateProgress(id: String, progress: Double?, progressText: String?, updatedAt: Long): Int
|
||||
|
||||
@Query("UPDATE background_tasks SET checkpoint = :checkpoint, updatedAt = :updatedAt WHERE id = :id")
|
||||
suspend fun updateCheckpoint(id: String, checkpoint: String?, updatedAt: Long)
|
||||
@Query("UPDATE background_tasks SET checkpoint = :checkpoint, updatedAt = :updatedAt WHERE id = :id AND state = 'running'")
|
||||
suspend fun updateCheckpoint(id: String, checkpoint: String?, updatedAt: Long): Int
|
||||
|
||||
@Query("UPDATE background_tasks SET workId = :workId, updatedAt = :updatedAt WHERE id = :id")
|
||||
suspend fun assignWork(id: String, workId: String, updatedAt: Long)
|
||||
|
||||
+12
-1
@@ -37,8 +37,19 @@ object BackgroundTaskScheduler {
|
||||
return request.id
|
||||
}
|
||||
|
||||
suspend fun cancel(context: Context, taskId: String) {
|
||||
suspend fun cancel(context: Context, taskId: String, isRunning: Boolean) {
|
||||
// A queued worker must finish as a no-op so WorkManager does not cancel
|
||||
// every task chained after it.
|
||||
if (!isRunning) return
|
||||
|
||||
val dao = BackgroundTaskDatabase.get(context).tasks()
|
||||
// The running worker may delete the row as it consumes cancellation.
|
||||
// Capture the queue before cancelling that worker.
|
||||
val queueName = dao.get(taskId)?.queueName ?: return
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(taskId).await()
|
||||
// Cancelling running work also cancels its dependents, so rebuild the
|
||||
// remaining queue after the worker has stopped.
|
||||
dao.getQueuedByQueueName(queueName).forEach { enqueue(context, it.id) }
|
||||
}
|
||||
|
||||
suspend fun enqueueLibraryUpdate(
|
||||
|
||||
+102
-41
@@ -8,6 +8,8 @@ import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class LNReaderTaskWorker(
|
||||
appContext: Context,
|
||||
@@ -16,8 +18,12 @@ class LNReaderTaskWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
val taskId = inputData.getString(BackgroundTaskScheduler.TASK_ID) ?: return Result.failure()
|
||||
val dao = BackgroundTaskDatabase.get(applicationContext).tasks()
|
||||
val task = dao.get(taskId) ?: return Result.failure()
|
||||
if (task.state == BackgroundTaskState.CANCELLED || task.state == BackgroundTaskState.PAUSED) {
|
||||
val task = dao.get(taskId) ?: return Result.success()
|
||||
if (task.state == BackgroundTaskState.CANCELLED) {
|
||||
consumeCancellation(dao, taskId)
|
||||
return Result.success()
|
||||
}
|
||||
if (task.state == BackgroundTaskState.PAUSED) {
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@@ -28,13 +34,23 @@ class LNReaderTaskWorker(
|
||||
MAX_CONCURRENT_DOWNLOADS,
|
||||
)
|
||||
if (claimed == 0) {
|
||||
return if (dao.get(taskId)?.state == BackgroundTaskState.QUEUED) {
|
||||
Result.retry()
|
||||
} else {
|
||||
Result.success()
|
||||
return when (dao.get(taskId)?.state) {
|
||||
BackgroundTaskState.QUEUED -> Result.retry()
|
||||
BackgroundTaskState.CANCELLED -> {
|
||||
consumeCancellation(dao, taskId)
|
||||
Result.success()
|
||||
}
|
||||
else -> Result.success()
|
||||
}
|
||||
}
|
||||
val runningTask = dao.get(taskId) ?: return Result.failure()
|
||||
val runningTask = dao.get(taskId) ?: return Result.success()
|
||||
if (runningTask.state != BackgroundTaskState.RUNNING) {
|
||||
if (runningTask.state == BackgroundTaskState.CANCELLED) {
|
||||
consumeCancellation(dao, taskId)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
setForeground(createForegroundInfo(runningTask))
|
||||
val execution = TaskExecutionRegistry.register(taskId)
|
||||
|
||||
@@ -47,61 +63,106 @@ class LNReaderTaskWorker(
|
||||
|
||||
when (val executionResult = execution.await()) {
|
||||
TaskExecutionResult.Success -> {
|
||||
val currentState = dao.get(taskId)?.state
|
||||
if (currentState == BackgroundTaskState.CANCELLED) {
|
||||
TaskNotificationFactory.dismiss(applicationContext, taskId)
|
||||
return Result.success()
|
||||
}
|
||||
if (currentState == BackgroundTaskState.PAUSED) {
|
||||
dao.get(taskId)?.let { TaskNotificationFactory.update(applicationContext, it) }
|
||||
return Result.success()
|
||||
}
|
||||
dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, System.currentTimeMillis())
|
||||
dao.get(taskId)?.let {
|
||||
TaskNotificationFactory.postTerminal(applicationContext, it)
|
||||
if (dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, System.currentTimeMillis()) > 0) {
|
||||
postOwnedTerminal(taskId, dao, BackgroundTaskState.SUCCEEDED)
|
||||
} else {
|
||||
resultAfterLostOwnership(taskId, dao)
|
||||
}
|
||||
Result.success()
|
||||
}
|
||||
is TaskExecutionResult.Failure -> {
|
||||
val currentState = dao.get(taskId)?.state
|
||||
if (currentState == BackgroundTaskState.PAUSED || currentState == BackgroundTaskState.CANCELLED) {
|
||||
return Result.success()
|
||||
}
|
||||
if (executionResult.shouldRetry) {
|
||||
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
|
||||
Result.retry()
|
||||
} else {
|
||||
dao.updateState(taskId, BackgroundTaskState.FAILED, System.currentTimeMillis())
|
||||
dao.get(taskId)?.let {
|
||||
TaskNotificationFactory.postTerminal(applicationContext, it)
|
||||
if (dao.finishRunning(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis()) > 0) {
|
||||
Result.retry()
|
||||
} else {
|
||||
resultAfterLostOwnership(taskId, dao)
|
||||
}
|
||||
} else if (
|
||||
dao.finishRunning(taskId, BackgroundTaskState.FAILED, System.currentTimeMillis()) > 0
|
||||
) {
|
||||
postOwnedTerminal(taskId, dao, BackgroundTaskState.FAILED)
|
||||
Result.success()
|
||||
} else {
|
||||
resultAfterLostOwnership(taskId, dao)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
val latestState = dao.get(taskId)?.state
|
||||
if (latestState !in listOf(BackgroundTaskState.PAUSED, BackgroundTaskState.CANCELLED)) {
|
||||
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
|
||||
withContext(NonCancellable) {
|
||||
when (dao.get(taskId)?.state) {
|
||||
BackgroundTaskState.CANCELLED -> consumeCancellation(dao, taskId)
|
||||
BackgroundTaskState.RUNNING -> dao.finishRunning(
|
||||
taskId,
|
||||
BackgroundTaskState.QUEUED,
|
||||
System.currentTimeMillis(),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
val latestState = dao.get(taskId)?.state
|
||||
if (latestState !in listOf(BackgroundTaskState.PAUSED, BackgroundTaskState.CANCELLED)) {
|
||||
val now = System.currentTimeMillis()
|
||||
val message = error.message ?: error.javaClass.simpleName
|
||||
dao.updateProgress(taskId, null, message, now)
|
||||
dao.updateState(taskId, BackgroundTaskState.FAILED, now)
|
||||
dao.get(taskId)?.let {
|
||||
TaskNotificationFactory.postTerminal(applicationContext, it)
|
||||
val now = System.currentTimeMillis()
|
||||
val message = error.message ?: error.javaClass.simpleName
|
||||
if (
|
||||
dao.finishRunningWithProgress(
|
||||
taskId,
|
||||
BackgroundTaskState.FAILED,
|
||||
null,
|
||||
message,
|
||||
now,
|
||||
) > 0
|
||||
) {
|
||||
postOwnedTerminal(taskId, dao, BackgroundTaskState.FAILED)
|
||||
Result.failure()
|
||||
} else {
|
||||
when (dao.get(taskId)?.state) {
|
||||
BackgroundTaskState.CANCELLED -> {
|
||||
consumeCancellation(dao, taskId)
|
||||
Result.success()
|
||||
}
|
||||
BackgroundTaskState.PAUSED -> Result.success()
|
||||
else -> Result.failure()
|
||||
}
|
||||
}
|
||||
Result.failure()
|
||||
} finally {
|
||||
TaskExecutionRegistry.cancel(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resultAfterLostOwnership(
|
||||
taskId: String,
|
||||
dao: BackgroundTaskDao,
|
||||
): Result {
|
||||
return when (dao.get(taskId)?.state) {
|
||||
BackgroundTaskState.CANCELLED -> {
|
||||
consumeCancellation(dao, taskId)
|
||||
Result.success()
|
||||
}
|
||||
BackgroundTaskState.PAUSED -> {
|
||||
dao.get(taskId)?.let { TaskNotificationFactory.update(applicationContext, it) }
|
||||
Result.success()
|
||||
}
|
||||
else -> Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun postOwnedTerminal(
|
||||
taskId: String,
|
||||
dao: BackgroundTaskDao,
|
||||
state: String,
|
||||
) {
|
||||
val terminalTask = dao.get(taskId) ?: return
|
||||
if (terminalTask.state != state) return
|
||||
TaskNotificationFactory.postTerminal(applicationContext, terminalTask)
|
||||
dao.deleteIfState(taskId, state)
|
||||
}
|
||||
|
||||
private suspend fun consumeCancellation(dao: BackgroundTaskDao, taskId: String) {
|
||||
if (dao.deleteIfState(taskId, BackgroundTaskState.CANCELLED) > 0) {
|
||||
TaskNotificationFactory.dismiss(applicationContext, taskId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(task: BackgroundTaskEntity): ForegroundInfo {
|
||||
val id = TaskNotificationFactory.notificationId(task.id)
|
||||
val notification = TaskNotificationFactory.build(applicationContext, task)
|
||||
|
||||
+42
-25
@@ -57,7 +57,10 @@ class NativeBackgroundTasksModule : Module() {
|
||||
|
||||
AsyncFunction("getTasks") {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
dao.getAll().map(::toRecord)
|
||||
dao.deleteOldTerminal(
|
||||
System.currentTimeMillis() - TERMINAL_TASK_RETENTION_MS,
|
||||
)
|
||||
dao.getActive().map(::toSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +73,12 @@ class NativeBackgroundTasksModule : Module() {
|
||||
AsyncFunction("pause") { taskId: String ->
|
||||
runBlocking(Dispatchers.IO) {
|
||||
requireTask(taskId)
|
||||
dao.updateState(taskId, BackgroundTaskState.PAUSED, System.currentTimeMillis())
|
||||
if (TaskExecutionRegistry.isActive(taskId)) {
|
||||
emitInterruption(taskId, "pause")
|
||||
if (dao.markPaused(taskId, System.currentTimeMillis()) > 0) {
|
||||
if (TaskExecutionRegistry.isActive(taskId)) {
|
||||
emitInterruption(taskId, "pause")
|
||||
}
|
||||
dao.get(taskId)?.let { TaskNotificationFactory.update(appContext.reactContext!!, it) }
|
||||
}
|
||||
dao.get(taskId)?.let { TaskNotificationFactory.update(appContext.reactContext!!, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,21 +88,29 @@ class NativeBackgroundTasksModule : Module() {
|
||||
if (TaskExecutionRegistry.isActive(taskId)) {
|
||||
throw IllegalStateException("Task is still pausing; try resuming again shortly")
|
||||
}
|
||||
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
|
||||
BackgroundTaskScheduler.enqueue(appContext.reactContext!!, taskId)
|
||||
if (dao.markQueued(taskId, System.currentTimeMillis()) > 0) {
|
||||
BackgroundTaskScheduler.enqueue(appContext.reactContext!!, taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("cancel") { taskId: String ->
|
||||
runBlocking(Dispatchers.IO) {
|
||||
requireTask(taskId)
|
||||
dao.updateState(taskId, BackgroundTaskState.CANCELLED, System.currentTimeMillis())
|
||||
if (TaskExecutionRegistry.isActive(taskId)) {
|
||||
val task = requireTask(taskId)
|
||||
val isRunning = task.state == BackgroundTaskState.RUNNING ||
|
||||
TaskExecutionRegistry.isActive(taskId)
|
||||
if (dao.markCancelled(taskId, System.currentTimeMillis()) == 0) {
|
||||
TaskNotificationFactory.dismiss(appContext.reactContext!!, taskId)
|
||||
return@runBlocking
|
||||
}
|
||||
if (isRunning) {
|
||||
emitInterruption(taskId, "cancel")
|
||||
}
|
||||
BackgroundTaskScheduler.cancel(appContext.reactContext!!, taskId)
|
||||
dao.updateCheckpoint(taskId, null, System.currentTimeMillis())
|
||||
BackgroundTaskScheduler.cancel(appContext.reactContext!!, taskId, isRunning)
|
||||
TaskNotificationFactory.dismiss(appContext.reactContext!!, taskId)
|
||||
if (!isRunning && task.state != BackgroundTaskState.QUEUED) {
|
||||
dao.deleteIfState(taskId, BackgroundTaskState.CANCELLED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,22 +138,13 @@ class NativeBackgroundTasksModule : Module() {
|
||||
val now = System.currentTimeMillis()
|
||||
dao.updateCheckpoint(taskId, null, now)
|
||||
dao.updateProgress(taskId, null, completionText, now)
|
||||
dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, now)
|
||||
TaskExecutionRegistry.complete(taskId, TaskExecutionResult.Success)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("fail") { taskId: String, error: String, shouldRetry: Boolean ->
|
||||
runBlocking(Dispatchers.IO) {
|
||||
val currentState = dao.get(taskId)?.state
|
||||
if (currentState !in listOf(BackgroundTaskState.PAUSED, BackgroundTaskState.CANCELLED)) {
|
||||
dao.updateProgress(taskId, null, error, System.currentTimeMillis())
|
||||
dao.finishRunning(
|
||||
taskId,
|
||||
if (shouldRetry) BackgroundTaskState.QUEUED else BackgroundTaskState.FAILED,
|
||||
System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
dao.updateProgress(taskId, null, error, System.currentTimeMillis())
|
||||
TaskExecutionRegistry.complete(taskId, TaskExecutionResult.Failure(error, shouldRetry))
|
||||
}
|
||||
}
|
||||
@@ -177,22 +180,36 @@ class NativeBackgroundTasksModule : Module() {
|
||||
private suspend fun requireTask(taskId: String): BackgroundTaskEntity =
|
||||
dao.get(taskId) ?: throw IllegalArgumentException("Unknown background task: $taskId")
|
||||
|
||||
private fun toRecord(task: BackgroundTaskEntity): Map<String, Any?> = mapOf(
|
||||
private fun toSummary(task: BackgroundTaskSummary): Map<String, Any?> = mapOf(
|
||||
"id" to task.id,
|
||||
"type" to task.type,
|
||||
"payload" to task.payload,
|
||||
"title" to task.title,
|
||||
"description" to task.description,
|
||||
"state" to task.state,
|
||||
"progress" to task.progress,
|
||||
"progressText" to task.progressText,
|
||||
"checkpoint" to task.checkpoint,
|
||||
"attempt" to task.attempt,
|
||||
"createdAt" to task.createdAt.toDouble(),
|
||||
"updatedAt" to task.updatedAt.toDouble(),
|
||||
)
|
||||
|
||||
private fun toRecord(task: BackgroundTaskEntity): Map<String, Any?> = mapOf(
|
||||
"id" to task.id,
|
||||
"type" to task.type,
|
||||
"title" to task.title,
|
||||
"description" to task.description,
|
||||
"state" to task.state,
|
||||
"progress" to task.progress,
|
||||
"progressText" to task.progressText,
|
||||
"attempt" to task.attempt,
|
||||
"createdAt" to task.createdAt.toDouble(),
|
||||
"updatedAt" to task.updatedAt.toDouble(),
|
||||
"payload" to task.payload,
|
||||
"checkpoint" to task.checkpoint,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TERMINAL_TASK_RETENTION_MS = 24 * 60 * 60 * 1000L
|
||||
@Volatile
|
||||
private var reactContextRef: WeakReference<ReactApplicationContext>? = null
|
||||
|
||||
|
||||
+20
-10
@@ -14,27 +14,37 @@ class TaskActionReceiver : BroadcastReceiver() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val dao = BackgroundTaskDatabase.get(context).tasks()
|
||||
dao.get(taskId) ?: return@launch
|
||||
val task = dao.get(taskId) ?: return@launch
|
||||
when (intent.action) {
|
||||
TaskNotificationFactory.ACTION_PAUSE -> {
|
||||
dao.updateState(taskId, BackgroundTaskState.PAUSED, System.currentTimeMillis())
|
||||
if (TaskExecutionRegistry.isActive(taskId)) {
|
||||
NativeBackgroundTasksModule.emitInterruption(taskId, "pause")
|
||||
if (dao.markPaused(taskId, System.currentTimeMillis()) > 0) {
|
||||
if (TaskExecutionRegistry.isActive(taskId)) {
|
||||
NativeBackgroundTasksModule.emitInterruption(taskId, "pause")
|
||||
}
|
||||
dao.get(taskId)?.let { TaskNotificationFactory.update(context, it) }
|
||||
}
|
||||
dao.get(taskId)?.let { TaskNotificationFactory.update(context, it) }
|
||||
}
|
||||
TaskNotificationFactory.ACTION_RESUME -> {
|
||||
if (TaskExecutionRegistry.isActive(taskId)) return@launch
|
||||
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
|
||||
BackgroundTaskScheduler.enqueue(context, taskId)
|
||||
if (dao.markQueued(taskId, System.currentTimeMillis()) > 0) {
|
||||
BackgroundTaskScheduler.enqueue(context, taskId)
|
||||
}
|
||||
}
|
||||
TaskNotificationFactory.ACTION_CANCEL -> {
|
||||
dao.updateState(taskId, BackgroundTaskState.CANCELLED, System.currentTimeMillis())
|
||||
if (TaskExecutionRegistry.isActive(taskId)) {
|
||||
val isRunning = task.state == BackgroundTaskState.RUNNING ||
|
||||
TaskExecutionRegistry.isActive(taskId)
|
||||
if (dao.markCancelled(taskId, System.currentTimeMillis()) == 0) {
|
||||
TaskNotificationFactory.dismiss(context, taskId)
|
||||
return@launch
|
||||
}
|
||||
if (isRunning) {
|
||||
NativeBackgroundTasksModule.emitInterruption(taskId, "cancel")
|
||||
}
|
||||
BackgroundTaskScheduler.cancel(context, taskId)
|
||||
BackgroundTaskScheduler.cancel(context, taskId, isRunning)
|
||||
TaskNotificationFactory.dismiss(context, taskId)
|
||||
if (!isRunning && task.state != BackgroundTaskState.QUEUED) {
|
||||
dao.deleteIfState(taskId, BackgroundTaskState.CANCELLED)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"platforms": ["apple", "android"],
|
||||
"apple": {
|
||||
"modules": ["NativeBackgroundTasksModule"]
|
||||
},
|
||||
"platforms": [
|
||||
"android"
|
||||
],
|
||||
"android": {
|
||||
"modules": ["expo.modules.nativebackgroundtasks.NativeBackgroundTasksModule"]
|
||||
"modules": [
|
||||
"expo.modules.nativebackgroundtasks.NativeBackgroundTasksModule"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import NativeBackgroundTasks, {
|
||||
NativeBackgroundTaskRecord,
|
||||
NativeBackgroundTaskSummary,
|
||||
} from './src/NativeBackgroundTasksModule';
|
||||
export default NativeBackgroundTasks;
|
||||
export { NativeBackgroundTaskRecord };
|
||||
export { NativeBackgroundTaskRecord, NativeBackgroundTaskSummary };
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// iOS has no WorkManager equivalent that can run these tasks reliably in the
|
||||
// background, so on iOS the queue lives in JS: the same contract as the Android
|
||||
// module (NativeBackgroundTasksModule.kt), persisted in MMKV, and executed in the
|
||||
// foreground through the same headless runner. Tasks pause while the app is
|
||||
// suspended and continue when it returns; periodic library updates and automatic
|
||||
// backups are not scheduled on iOS.
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
import { getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
|
||||
import type {
|
||||
NativeBackgroundTaskRecord,
|
||||
NativeBackgroundTaskSummary,
|
||||
} from './NativeBackgroundTasksModule';
|
||||
|
||||
export type { NativeBackgroundTaskRecord, NativeBackgroundTaskSummary };
|
||||
|
||||
type State =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'paused'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
type Task = NativeBackgroundTaskRecord & {
|
||||
state: State;
|
||||
queueName: string;
|
||||
retryAt?: number; // a retried task waits RETRY_DELAY_MS before it may run
|
||||
};
|
||||
|
||||
type Result = { ok: true } | { ok: false; error: string; retry: boolean };
|
||||
|
||||
const STORE_KEY = 'IOS_BACKGROUND_TASKS';
|
||||
const ACTIVE: ReadonlySet<string> = new Set(['queued', 'running', 'paused']);
|
||||
const TERMINAL_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_CONCURRENT_DOWNLOADS = 3; // as LNReaderTaskWorker.kt
|
||||
const RETRY_DELAY_MS = 5000;
|
||||
|
||||
const load = () => getMMKVObject<Task[]>(STORE_KEY) || [];
|
||||
const save = (tasks: Task[]) => setMMKVObject(STORE_KEY, tasks);
|
||||
|
||||
const update = (taskId: string, change: (task: Task) => Task | null) => {
|
||||
const tasks = load();
|
||||
const index = tasks.findIndex(task => task.id === taskId);
|
||||
if (index < 0) return undefined;
|
||||
const next = change(tasks[index]);
|
||||
if (next) tasks[index] = { ...next, updatedAt: Date.now() };
|
||||
else tasks.splice(index, 1);
|
||||
save(tasks);
|
||||
return next ?? undefined;
|
||||
};
|
||||
|
||||
const requireTask = (taskId: string) => {
|
||||
const task = load().find(item => item.id === taskId);
|
||||
if (!task) throw new Error(`Unknown background task: ${taskId}`);
|
||||
return task;
|
||||
};
|
||||
|
||||
const summary = (task: Task): NativeBackgroundTaskSummary => ({
|
||||
id: task.id,
|
||||
type: task.type,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
state: task.state,
|
||||
progress: task.progress,
|
||||
progressText: task.progressText,
|
||||
attempt: task.attempt,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
});
|
||||
|
||||
const record = (task: Task): NativeBackgroundTaskRecord => ({
|
||||
...summary(task),
|
||||
payload: task.payload,
|
||||
checkpoint: task.checkpoint,
|
||||
});
|
||||
|
||||
const makeId = () =>
|
||||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
|
||||
// ---- execution -------------------------------------------------------------
|
||||
|
||||
// Tasks whose runner is in progress, with how complete()/fail() settle them.
|
||||
const executing = new Map<string, (result: Result) => void>();
|
||||
|
||||
const interrupt = (taskId: string, action: 'pause' | 'cancel') => {
|
||||
if (executing.has(taskId)) {
|
||||
DeviceEventEmitter.emit('LNReaderTaskInterrupted', { taskId, action });
|
||||
}
|
||||
};
|
||||
|
||||
const settle = (taskId: string, result: Result) => {
|
||||
executing.get(taskId)?.(result);
|
||||
};
|
||||
|
||||
let pumping = false;
|
||||
|
||||
// Start every task that may run now: one per queue, at most
|
||||
// MAX_CONCURRENT_DOWNLOADS chapter downloads at once.
|
||||
const pump = () => {
|
||||
if (pumping) return;
|
||||
pumping = true;
|
||||
try {
|
||||
const tasks = load();
|
||||
const busyQueues = new Set(
|
||||
tasks.filter(t => t.state === 'running').map(t => t.queueName),
|
||||
);
|
||||
let downloads = tasks.filter(
|
||||
t => t.state === 'running' && t.type === 'DOWNLOAD_CHAPTER',
|
||||
).length;
|
||||
const now = Date.now();
|
||||
const waiting = tasks
|
||||
.filter(t => t.state === 'queued' && !(t.retryAt && t.retryAt > now))
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const task of waiting) {
|
||||
if (busyQueues.has(task.queueName)) continue;
|
||||
if (task.type === 'DOWNLOAD_CHAPTER') {
|
||||
if (downloads >= MAX_CONCURRENT_DOWNLOADS) continue;
|
||||
downloads += 1;
|
||||
}
|
||||
busyQueues.add(task.queueName);
|
||||
start(task.id);
|
||||
}
|
||||
} finally {
|
||||
pumping = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = (taskId: string) => {
|
||||
const claimed = update(taskId, task =>
|
||||
task.state === 'queued' ? { ...task, state: 'running' } : task,
|
||||
);
|
||||
if (claimed?.state !== 'running') return;
|
||||
|
||||
let result: Result | undefined;
|
||||
const settled = new Promise<void>(resolve => {
|
||||
executing.set(taskId, value => {
|
||||
result ??= value;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// Required lazily: the runner imports this module.
|
||||
const { runHeadlessBackgroundTask } =
|
||||
require('../../../src/services/backgroundTasks/headlessTask') as typeof import('../../../src/services/backgroundTasks/headlessTask');
|
||||
const run = runHeadlessBackgroundTask({ taskId }).catch((error: unknown) => {
|
||||
result ??= {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
retry: false,
|
||||
};
|
||||
});
|
||||
|
||||
Promise.race([settled, run])
|
||||
.then(() => run)
|
||||
.finally(() => {
|
||||
executing.delete(taskId);
|
||||
finish(taskId, result);
|
||||
pump();
|
||||
});
|
||||
};
|
||||
|
||||
const finish = (taskId: string, result: Result | undefined) => {
|
||||
update(taskId, task => {
|
||||
if (task.state === 'cancelled') return null;
|
||||
if (task.state !== 'running') return task; // paused meanwhile
|
||||
if (!result) {
|
||||
return {
|
||||
...task,
|
||||
state: 'failed',
|
||||
progressText: 'The task ended without a result',
|
||||
};
|
||||
}
|
||||
if (result.ok) {
|
||||
return { ...task, state: 'succeeded', progress: undefined };
|
||||
}
|
||||
if (result.retry) {
|
||||
return {
|
||||
...task,
|
||||
state: 'queued',
|
||||
attempt: task.attempt + 1,
|
||||
retryAt: Date.now() + RETRY_DELAY_MS,
|
||||
};
|
||||
}
|
||||
return { ...task, state: 'failed', progressText: result.error };
|
||||
});
|
||||
const after = load().find(task => task.id === taskId);
|
||||
if (after?.retryAt) setTimeout(pump, RETRY_DELAY_MS + 50);
|
||||
};
|
||||
|
||||
// A task that was running when the app was closed is queued again.
|
||||
save(
|
||||
load().map(task =>
|
||||
task.state === 'running' ? { ...task, state: 'queued' } : task,
|
||||
),
|
||||
);
|
||||
setTimeout(pump, 1500);
|
||||
|
||||
// ---- module contract ---------------------------------------------------------
|
||||
|
||||
const NativeBackgroundTasks = {
|
||||
async getTasks(): Promise<NativeBackgroundTaskSummary[]> {
|
||||
const now = Date.now();
|
||||
const kept = load().filter(
|
||||
task =>
|
||||
ACTIVE.has(task.state) || now - task.updatedAt < TERMINAL_RETENTION_MS,
|
||||
);
|
||||
save(kept);
|
||||
return kept.filter(task => ACTIVE.has(task.state)).map(summary);
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<NativeBackgroundTaskRecord | null> {
|
||||
const task = load().find(item => item.id === taskId);
|
||||
return task ? record(task) : null;
|
||||
},
|
||||
|
||||
async enqueue(
|
||||
type: string,
|
||||
payload: string,
|
||||
title: string,
|
||||
description: string,
|
||||
allowsDuplicates: boolean,
|
||||
queueName: string,
|
||||
): Promise<string> {
|
||||
const tasks = load();
|
||||
if (!allowsDuplicates) {
|
||||
const active = tasks.find(t => t.type === type && ACTIVE.has(t.state));
|
||||
if (active) return active.id;
|
||||
}
|
||||
const now = Date.now();
|
||||
const task: Task = {
|
||||
id: makeId(),
|
||||
type,
|
||||
payload,
|
||||
title,
|
||||
description,
|
||||
queueName,
|
||||
state: 'queued',
|
||||
attempt: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
save([...tasks, task]);
|
||||
pump();
|
||||
return task.id;
|
||||
},
|
||||
|
||||
async pause(taskId: string): Promise<void> {
|
||||
requireTask(taskId);
|
||||
const paused = update(taskId, task =>
|
||||
task.state === 'queued' || task.state === 'running'
|
||||
? { ...task, state: 'paused' }
|
||||
: task,
|
||||
);
|
||||
if (paused?.state === 'paused') interrupt(taskId, 'pause');
|
||||
},
|
||||
|
||||
async resume(taskId: string): Promise<void> {
|
||||
requireTask(taskId);
|
||||
if (executing.has(taskId)) {
|
||||
throw new Error('Task is still pausing; try resuming again shortly');
|
||||
}
|
||||
update(taskId, task =>
|
||||
task.state === 'paused'
|
||||
? { ...task, state: 'queued', retryAt: undefined }
|
||||
: task,
|
||||
);
|
||||
pump();
|
||||
},
|
||||
|
||||
async cancel(taskId: string): Promise<void> {
|
||||
const task = requireTask(taskId);
|
||||
const running = task.state === 'running' || executing.has(taskId);
|
||||
if (running) {
|
||||
update(taskId, item => ({ ...item, state: 'cancelled' }));
|
||||
interrupt(taskId, 'cancel');
|
||||
} else {
|
||||
update(taskId, () => null);
|
||||
}
|
||||
},
|
||||
|
||||
async updateProgress(
|
||||
taskId: string,
|
||||
progress: number,
|
||||
progressText: string,
|
||||
): Promise<void> {
|
||||
update(taskId, task => ({
|
||||
...task,
|
||||
progress: progress < 0 ? undefined : progress,
|
||||
progressText: progressText || undefined,
|
||||
}));
|
||||
},
|
||||
|
||||
async updateCheckpoint(taskId: string, checkpoint: string): Promise<void> {
|
||||
requireTask(taskId);
|
||||
update(taskId, task => ({ ...task, checkpoint }));
|
||||
},
|
||||
|
||||
async complete(taskId: string, completionText: string): Promise<void> {
|
||||
update(taskId, task => ({
|
||||
...task,
|
||||
checkpoint: undefined,
|
||||
progress: undefined,
|
||||
progressText: completionText,
|
||||
}));
|
||||
settle(taskId, { ok: true });
|
||||
},
|
||||
|
||||
async fail(
|
||||
taskId: string,
|
||||
error: string,
|
||||
shouldRetry: boolean,
|
||||
): Promise<void> {
|
||||
update(taskId, task => ({
|
||||
...task,
|
||||
progress: undefined,
|
||||
progressText: error,
|
||||
}));
|
||||
settle(taskId, { ok: false, error, retry: shouldRetry });
|
||||
},
|
||||
|
||||
// No reliable periodic background execution on iOS: library updates and
|
||||
// automatic backups run when started from the app.
|
||||
async scheduleLibraryUpdates(): Promise<void> {},
|
||||
async cancelLibraryUpdates(): Promise<void> {},
|
||||
async scheduleAutomaticBackups(): Promise<void> {},
|
||||
async cancelAutomaticBackups(): Promise<void> {},
|
||||
};
|
||||
|
||||
export default NativeBackgroundTasks;
|
||||
@@ -1,21 +1,27 @@
|
||||
import { requireNativeModule } from 'expo-modules-core';
|
||||
|
||||
export type NativeBackgroundTaskRecord = {
|
||||
export type NativeBackgroundTaskSummary = {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
state: string;
|
||||
progress?: number;
|
||||
progressText?: string;
|
||||
checkpoint?: string;
|
||||
attempt: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type NativeBackgroundTaskRecord = NativeBackgroundTaskSummary & {
|
||||
payload: string;
|
||||
checkpoint?: string;
|
||||
};
|
||||
|
||||
type NativeBackgroundTasksModule = {
|
||||
getTasks(): Promise<NativeBackgroundTaskSummary[]>;
|
||||
getTask(taskId: string): Promise<NativeBackgroundTaskRecord | null>;
|
||||
|
||||
enqueue(
|
||||
type: string,
|
||||
payload: string,
|
||||
@@ -24,8 +30,6 @@ type NativeBackgroundTasksModule = {
|
||||
allowsDuplicates: boolean,
|
||||
queueName: string,
|
||||
): Promise<string>;
|
||||
getTasks(): Promise<NativeBackgroundTaskRecord[]>;
|
||||
getTask(taskId: string): Promise<NativeBackgroundTaskRecord | null>;
|
||||
pause(taskId: string): Promise<void>;
|
||||
resume(taskId: string): Promise<void>;
|
||||
cancel(taskId: string): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'expo.modules.nativecrashlogs'
|
||||
version = '0.1.0'
|
||||
|
||||
android {
|
||||
namespace "expo.modules.nativecrashlogs"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.17.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<provider
|
||||
android:name="expo.modules.nativecrashlogs.CrashHandlerInitializationProvider"
|
||||
android:authorities="${applicationId}.crash-handler-initializer"
|
||||
android:exported="false"
|
||||
android:initOrder="110" />
|
||||
|
||||
<provider
|
||||
android:name="expo.modules.nativecrashlogs.CrashLogFileProvider"
|
||||
android:authorities="${applicationId}.crash-logs"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/crash_log_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Process
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
internal class CrashExceptionHandler private constructor(
|
||||
private val applicationContext: Context,
|
||||
private val defaultHandler: Thread.UncaughtExceptionHandler?,
|
||||
) : Thread.UncaughtExceptionHandler {
|
||||
override fun uncaughtException(thread: Thread, exception: Throwable) {
|
||||
try {
|
||||
val recordedJsCrash = CrashLogStore.readPendingCrash(applicationContext)
|
||||
CrashLogStore.writePendingCrash(
|
||||
applicationContext,
|
||||
buildString {
|
||||
appendLine("Native crash captured: ${timestamp()}")
|
||||
appendLine("Thread: ${thread.name}")
|
||||
appendLine()
|
||||
append(exception.stackTraceToString())
|
||||
if (recordedJsCrash != null) {
|
||||
appendLine()
|
||||
appendLine()
|
||||
appendLine("Previously recorded JavaScript crash:")
|
||||
append(recordedJsCrash)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// The platform handler must still terminate the process normally.
|
||||
} finally {
|
||||
val handler = defaultHandler
|
||||
if (handler != null) {
|
||||
handler.uncaughtException(thread, exception)
|
||||
} else {
|
||||
Process.killProcess(Process.myPid())
|
||||
exitProcess(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun install(context: Context) {
|
||||
val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
if (currentHandler is CrashExceptionHandler) return
|
||||
Thread.setDefaultUncaughtExceptionHandler(
|
||||
CrashExceptionHandler(context.applicationContext, currentHandler),
|
||||
)
|
||||
}
|
||||
|
||||
private fun timestamp(): String =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT).format(Date())
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
|
||||
class CrashHandlerInitializationProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
context?.let(CrashExceptionHandler::install)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor? = null
|
||||
|
||||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
): Int = 0
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
class CrashLogFileProvider : FileProvider(R.xml.crash_log_paths)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
internal object CrashLogRedactor {
|
||||
private val headerPattern = Regex(
|
||||
pattern = "(?i)(authorization|cookie|set-cookie)(\\s*[:=]\\s*).*$",
|
||||
options = setOf(RegexOption.MULTILINE),
|
||||
)
|
||||
private val urlQueryPattern = Regex(
|
||||
pattern = "(?i)(https?://[^\\s?#]+)\\?[^\\s#\"')\\]]+",
|
||||
)
|
||||
|
||||
fun redact(contents: String): String = contents
|
||||
.replace(headerPattern, "$1$2[REDACTED]")
|
||||
.replace(urlQueryPattern, "$1?[REDACTED]")
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
internal object CrashLogStore {
|
||||
private const val CRASH_DIRECTORY = "crash-reports"
|
||||
private const val PENDING_CRASH_FILE = "last_crash.txt"
|
||||
private const val SHARE_DIRECTORY = "crash-logs"
|
||||
private const val SHARE_FILE = "lnreader_crash_logs.txt"
|
||||
|
||||
fun createShareFile(context: Context): File {
|
||||
val directory = File(context.cacheDir, SHARE_DIRECTORY)
|
||||
check(directory.exists() || directory.mkdirs()) {
|
||||
"Failed to create crash log cache directory"
|
||||
}
|
||||
return File(directory, SHARE_FILE)
|
||||
}
|
||||
|
||||
fun readPendingCrash(context: Context): String? {
|
||||
val file = pendingCrashFile(context)
|
||||
if (!file.isFile) return null
|
||||
return file.readText().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun writePendingCrash(context: Context, contents: String) {
|
||||
val file = pendingCrashFile(context)
|
||||
val directory = file.parentFile
|
||||
check(directory != null && (directory.exists() || directory.mkdirs())) {
|
||||
"Failed to create crash report directory"
|
||||
}
|
||||
|
||||
val temporaryFile = File(directory, "$PENDING_CRASH_FILE.tmp")
|
||||
temporaryFile.writeText(contents)
|
||||
if (!temporaryFile.renameTo(file)) {
|
||||
file.writeText(contents)
|
||||
temporaryFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPendingCrash(context: Context) {
|
||||
val file = pendingCrashFile(context)
|
||||
check(!file.exists() || file.delete()) { "Failed to clear pending crash report" }
|
||||
}
|
||||
|
||||
private fun pendingCrashFile(context: Context): File =
|
||||
File(File(context.filesDir, CRASH_DIRECTORY), PENDING_CRASH_FILE)
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal object LogcatCollector {
|
||||
private const val MAX_LOG_BYTES = 512 * 1024
|
||||
private const val MAX_LOG_LINES = 2_000
|
||||
private const val LOGCAT_TIMEOUT_SECONDS = 2L
|
||||
|
||||
fun collect(context: Context, requestedLevel: String): String {
|
||||
val level = if (requestedLevel == "V") "V" else "E"
|
||||
val output = File.createTempFile("lnreader-logcat-", ".tmp", context.cacheDir)
|
||||
return try {
|
||||
val process = ProcessBuilder(
|
||||
"logcat",
|
||||
"-d",
|
||||
"-t",
|
||||
MAX_LOG_LINES.toString(),
|
||||
"-v",
|
||||
"year",
|
||||
"-v",
|
||||
"zone",
|
||||
"*:$level",
|
||||
)
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(output)
|
||||
.start()
|
||||
|
||||
if (!process.waitFor(LOGCAT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
}
|
||||
readBounded(output)
|
||||
} catch (error: Exception) {
|
||||
"Logcat unavailable: ${error.message ?: error.javaClass.simpleName}"
|
||||
} finally {
|
||||
output.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBounded(file: File): String {
|
||||
if (!file.isFile || file.length() == 0L) return "No accessible logcat entries."
|
||||
|
||||
val wasTruncated = file.length() > MAX_LOG_BYTES
|
||||
val bytes = file.inputStream().use { input ->
|
||||
var bytesToSkip = if (wasTruncated) file.length() - MAX_LOG_BYTES else 0L
|
||||
while (bytesToSkip > 0) {
|
||||
val skipped = input.skip(bytesToSkip)
|
||||
if (skipped <= 0) break
|
||||
bytesToSkip -= skipped
|
||||
}
|
||||
val buffer = ByteArray(MAX_LOG_BYTES)
|
||||
var totalRead = 0
|
||||
while (totalRead < buffer.size) {
|
||||
val count = input.read(buffer, totalRead, buffer.size - totalRead)
|
||||
if (count == -1) break
|
||||
totalRead += count
|
||||
}
|
||||
buffer.copyOf(totalRead)
|
||||
}
|
||||
val sanitized = bytes.toString(Charsets.UTF_8)
|
||||
.lineSequence()
|
||||
.joinToString("\n", transform = CrashLogRedactor::redact)
|
||||
|
||||
return if (wasTruncated) {
|
||||
"[Older logcat entries truncated]\n$sanitized"
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.webkit.WebView
|
||||
import androidx.core.content.FileProvider
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NativeCrashLogsModule : Module() {
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("NativeCrashLogs")
|
||||
|
||||
OnCreate {
|
||||
appContext.reactContext?.let(CrashExceptionHandler::install)
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
AsyncFunction("shareCrashLogs") { report: String, logLevel: String, promise: Promise ->
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val context = requireNotNull(appContext.reactContext) {
|
||||
"React context is unavailable"
|
||||
}
|
||||
val file = CrashLogStore.createShareFile(context)
|
||||
file.writeText(
|
||||
CrashLogRedactor.redact(
|
||||
report.take(MAX_REPORT_CHARACTERS).trimEnd(),
|
||||
),
|
||||
)
|
||||
file.appendText("\n\n[App-accessible logcat]\n")
|
||||
file.appendText(LogcatCollector.collect(context, logLevel))
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
shareFile(context, file)
|
||||
}
|
||||
promise.resolve(null)
|
||||
} catch (error: Exception) {
|
||||
promise.reject("ECRASHLOG", "Failed to share crash logs", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Function("recordJsCrash") { stackTrace: String ->
|
||||
val context = requireNotNull(appContext.reactContext) {
|
||||
"React context is unavailable"
|
||||
}
|
||||
CrashLogStore.writePendingCrash(context, stackTrace.take(MAX_REPORT_CHARACTERS))
|
||||
}
|
||||
|
||||
AsyncFunction("getPendingCrash") { promise: Promise ->
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val context = requireNotNull(appContext.reactContext) {
|
||||
"React context is unavailable"
|
||||
}
|
||||
promise.resolve(CrashLogStore.readPendingCrash(context))
|
||||
} catch (error: Exception) {
|
||||
promise.reject("ECRASHREAD", "Failed to read pending crash", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("clearPendingCrash") { promise: Promise ->
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val context = requireNotNull(appContext.reactContext) {
|
||||
"React context is unavailable"
|
||||
}
|
||||
CrashLogStore.clearPendingCrash(context)
|
||||
promise.resolve(null)
|
||||
} catch (error: Exception) {
|
||||
promise.reject("ECRASHCLEAR", "Failed to clear pending crash", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("getDebugInfo") { promise: Promise ->
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val context = requireNotNull(appContext.reactContext) {
|
||||
"React context is unavailable"
|
||||
}
|
||||
val webViewVersion = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
WebView.getCurrentWebViewPackage()?.versionName
|
||||
} else {
|
||||
null
|
||||
}
|
||||
promise.resolve(mapOf("webViewVersion" to webViewVersion))
|
||||
} catch (error: Exception) {
|
||||
promise.reject("EDEBUGINFO", "Failed to read native debug info", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Function("restartApp") {
|
||||
val context = requireNotNull(appContext.reactContext) {
|
||||
"React context is unavailable"
|
||||
}
|
||||
val launchIntent = requireNotNull(
|
||||
context.packageManager.getLaunchIntentForPackage(context.packageName),
|
||||
) { "Application launch intent is unavailable" }
|
||||
try {
|
||||
CrashLogStore.clearPendingCrash(context)
|
||||
} catch (_: Exception) {
|
||||
// Restart remains useful even if stale crash cleanup fails.
|
||||
}
|
||||
launchIntent.addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP,
|
||||
)
|
||||
val activity = appContext.currentActivity
|
||||
if (activity == null) {
|
||||
context.startActivity(launchIntent)
|
||||
} else {
|
||||
activity.runOnUiThread {
|
||||
activity.finishAffinity()
|
||||
activity.startActivity(launchIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareFile(context: Context, file: java.io.File) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.crash-logs",
|
||||
file,
|
||||
)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
clipData = ClipData.newRawUri("LNReader crash logs", uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooser = Intent.createChooser(shareIntent, "Share crash logs")
|
||||
if (appContext.currentActivity == null) {
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
} else {
|
||||
appContext.currentActivity?.startActivity(chooser)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_REPORT_CHARACTERS = 256 * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="crash_logs" path="crash-logs/" />
|
||||
</paths>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CrashLogRedactorTest {
|
||||
@Test
|
||||
fun redactsSensitiveHeaders() {
|
||||
val report = "Authorization: Bearer secret\nCookie=session-secret\nmessage"
|
||||
|
||||
assertEquals(
|
||||
"Authorization: [REDACTED]\nCookie=[REDACTED]\nmessage",
|
||||
CrashLogRedactor.redact(report),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redactsUrlQueries() {
|
||||
val report = "Request failed: https://example.com/chapter?id=12&token=secret"
|
||||
|
||||
assertEquals(
|
||||
"Request failed: https://example.com/chapter?[REDACTED]",
|
||||
CrashLogRedactor.redact(report),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.nativecrashlogs.NativeCrashLogsModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import NativeCrashLogs from './src/NativeCrashLogsModule';
|
||||
|
||||
export default NativeCrashLogs;
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "native-crash-logs"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { requireOptionalNativeModule } from 'expo-modules-core';
|
||||
|
||||
export type NativeCrashDebugInfo = {
|
||||
webViewVersion: string | null;
|
||||
};
|
||||
|
||||
export type NativeCrashLogsModule = {
|
||||
shareCrashLogs(report: string, logLevel: 'E' | 'V'): Promise<void>;
|
||||
recordJsCrash(stackTrace: string): void;
|
||||
getPendingCrash(): Promise<string | null>;
|
||||
clearPendingCrash(): Promise<void>;
|
||||
getDebugInfo(): Promise<NativeCrashDebugInfo>;
|
||||
restartApp(): void;
|
||||
};
|
||||
|
||||
export default requireOptionalNativeModule<NativeCrashLogsModule>(
|
||||
'NativeCrashLogs',
|
||||
);
|
||||
@@ -96,9 +96,44 @@ public class NativeFileModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
// As the Android module: GET, or POST with the body for any other method; the file
|
||||
// is written only for a 2xx response. URLSession undoes gzip Content-Encoding itself.
|
||||
AsyncFunction("downloadFile") { (url: String, destPath: String, method: String, headers: [String: String], body: String?, promise: Promise) in
|
||||
// Stub — download implementation not ported for iOS
|
||||
promise.reject("NOT_IMPLEMENTED", "downloadFile is not implemented on iOS")
|
||||
guard let target = URL(string: url) else {
|
||||
promise.reject("DOWNLOAD_FAILED", "Invalid URL: \(url)")
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: target)
|
||||
if method.lowercased() != "get", let body = body {
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body.data(using: .utf8)
|
||||
}
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
URLSession.shared.downloadTask(with: request) { location, response, error in
|
||||
if let error = error {
|
||||
promise.reject("DOWNLOAD_FAILED", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
guard (200..<300).contains(status), let location = location else {
|
||||
promise.reject("DOWNLOAD_FAILED", "Failed to download: \(status)")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let destination = URL(fileURLWithPath: destPath)
|
||||
let files = FileManager.default
|
||||
try files.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
if files.fileExists(atPath: destPath) {
|
||||
try files.removeItem(at: destination)
|
||||
}
|
||||
try files.moveItem(at: location, to: destination)
|
||||
promise.resolve(nil)
|
||||
} catch {
|
||||
promise.reject("DOWNLOAD_FAILED", error.localizedDescription)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
Constant("DocumentDirectoryPath") {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'expo.modules.nativesharereceiver'
|
||||
version = '0.1.0'
|
||||
|
||||
android {
|
||||
namespace "expo.modules.nativesharereceiver"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package expo.modules.nativesharereceiver
|
||||
|
||||
import android.content.Intent
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class NativeShareReceiverModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("NativeShareReceiver")
|
||||
|
||||
Events("SharedText")
|
||||
|
||||
Function("getInitialSharedText") {
|
||||
val intent = appContext.currentActivity?.intent
|
||||
val text = extractSharedText(intent)
|
||||
if (text != null) {
|
||||
// Consume the share so JS reloads (Fast Refresh, OTA) that re-read
|
||||
// the activity intent don't re-deliver the same text.
|
||||
intent?.removeExtra(Intent.EXTRA_TEXT)
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
OnNewIntent { intent ->
|
||||
extractSharedText(intent)?.let { text ->
|
||||
sendEvent("SharedText", mapOf("text" to text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractSharedText(intent: Intent?): String? {
|
||||
if (intent?.action != Intent.ACTION_SEND) {
|
||||
return null
|
||||
}
|
||||
return intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.nativesharereceiver.NativeShareReceiverModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import NativeShareReceiver from './src/NativeShareReceiverModule';
|
||||
export default NativeShareReceiver;
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "native-share-receiver"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// "Share to LNReader" is Android-only (an intent filter); on iOS nothing is ever shared in.
|
||||
import { EventEmitter } from 'expo-modules-core';
|
||||
|
||||
export type NativeShareReceiverEvents = {
|
||||
SharedText: (payload: { text: string }) => void;
|
||||
};
|
||||
|
||||
class NativeShareReceiverStub extends EventEmitter<NativeShareReceiverEvents> {
|
||||
getInitialSharedText(): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default new NativeShareReceiverStub();
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NativeModule, requireNativeModule } from 'expo-modules-core';
|
||||
|
||||
export type NativeShareReceiverEvents = {
|
||||
SharedText: (payload: { text: string }) => void;
|
||||
};
|
||||
|
||||
declare class NativeShareReceiverModule extends NativeModule<NativeShareReceiverEvents> {
|
||||
getInitialSharedText(): string | null;
|
||||
}
|
||||
|
||||
export default requireNativeModule<NativeShareReceiverModule>(
|
||||
'NativeShareReceiver',
|
||||
);
|
||||
@@ -6,5 +6,11 @@ public class NativeVolumeButtonListenerModule: Module {
|
||||
Name("NativeVolumeButtonListener")
|
||||
|
||||
Events("VolumeUp", "VolumeDown")
|
||||
|
||||
// The reader calls this on every chapter. Page turning with the volume buttons is
|
||||
// Android-only (iOS gives apps no volume-key events), so it only records the choice.
|
||||
Function("setActive") { (active: Bool) in
|
||||
_ = active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lnreader",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:android:release": "pnpm run generate:env:release && npx expo run:android --variant release",
|
||||
@@ -33,7 +33,8 @@
|
||||
"check": "pnpm run format:check && pnpm run lint && pnpm run type-check",
|
||||
"prepare": "husky install",
|
||||
"nix:shell": "nix develop --extra-experimental-features nix-command --extra-experimental-features flakes",
|
||||
"nix:emulator": "nix develop .#emulator --extra-experimental-features nix-command --extra-experimental-features flakes"
|
||||
"nix:emulator": "nix develop .#emulator --extra-experimental-features nix-command --extra-experimental-features flakes",
|
||||
"postinstall": "node scripts/embed-reader-assets.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
@@ -72,6 +73,7 @@
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18n-js": "^4.5.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"prettier": "2.8.8",
|
||||
"protobufjs": "^8.7.1",
|
||||
"react": "19.2.3",
|
||||
"react-native": "^0.86.0",
|
||||
@@ -133,7 +135,6 @@
|
||||
"jest-expo": "^57.0.2",
|
||||
"lint-staged": "^12.5.0",
|
||||
"nitrogen": "^0.36.1",
|
||||
"prettier": "2.8.8",
|
||||
"react-native-nitro-modules": "^0.36.0",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"typescript": "~6.0.3"
|
||||
@@ -148,6 +149,7 @@
|
||||
"reactNativeDirectoryCheck": {
|
||||
"exclude": [
|
||||
"native-file",
|
||||
"native-crash-logs",
|
||||
"nitro-tts",
|
||||
"native-volume-button-listener",
|
||||
"native-zip-archive",
|
||||
|
||||
Generated
+3
-3
@@ -119,6 +119,9 @@ importers:
|
||||
lodash-es:
|
||||
specifier: ^4.18.1
|
||||
version: 4.18.1
|
||||
prettier:
|
||||
specifier: 2.8.8
|
||||
version: 2.8.8
|
||||
protobufjs:
|
||||
specifier: ^8.7.1
|
||||
version: 8.7.1
|
||||
@@ -297,9 +300,6 @@ importers:
|
||||
nitrogen:
|
||||
specifier: ^0.36.1
|
||||
version: 0.36.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
|
||||
prettier:
|
||||
specifier: 2.8.8
|
||||
version: 2.8.8
|
||||
react-native-nitro-modules:
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build an unsigned LNReader .ipa for sideloading. SideStore (or AltStore) signs it
|
||||
# on install with a free Apple ID; see docs/ios-sidestore.md.
|
||||
#
|
||||
# Runs on macOS with Xcode (and its command line tools), Node >= 22.11, pnpm and
|
||||
# CocoaPods. Output: build/LNReader-unsigned.ipa
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[[ "$(uname)" == "Darwin" ]] || { echo "build-ios-unsigned: needs macOS with Xcode" >&2; exit 1; }
|
||||
command -v xcodebuild >/dev/null || { echo "build-ios-unsigned: install Xcode first" >&2; exit 1; }
|
||||
command -v pod >/dev/null || { echo "build-ios-unsigned: install CocoaPods (brew install cocoapods)" >&2; exit 1; }
|
||||
|
||||
pnpm install --frozen-lockfile # postinstall embeds the reader assets for iOS
|
||||
pnpm run generate:env:release
|
||||
pnpm exec expo prebuild --platform ios --clean # also runs pod install
|
||||
|
||||
xcodebuild \
|
||||
-workspace ios/LNReader.xcworkspace \
|
||||
-scheme LNReader \
|
||||
-configuration Release \
|
||||
-sdk iphoneos \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-derivedDataPath build/ios \
|
||||
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \
|
||||
build
|
||||
|
||||
app=build/ios/Build/Products/Release-iphoneos/LNReader.app
|
||||
[[ -d "$app" ]] || { echo "build-ios-unsigned: $app was not produced" >&2; exit 1; }
|
||||
rm -rf build/ipa build/LNReader-unsigned.ipa
|
||||
mkdir -p build/ipa/Payload
|
||||
cp -R "$app" build/ipa/Payload/
|
||||
(cd build/ipa && zip -qry ../LNReader-unsigned.ipa Payload)
|
||||
rm -rf build/ipa
|
||||
echo "Built build/LNReader-unsigned.ipa ($(du -h build/LNReader-unsigned.ipa | cut -f1))"
|
||||
@@ -0,0 +1,21 @@
|
||||
// Embeds assets/reader (css, js, fonts) into a TS module for iOS, where WKWebView cannot
|
||||
// load file:// subresources into a chapter whose base URL is the source's site.
|
||||
// Run by `pnpm postinstall` and the iOS build script; the output is not committed.
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, parse } from 'node:path';
|
||||
|
||||
const root = join(import.meta.dirname, '..', 'assets', 'reader');
|
||||
const read = (dir, encoding) =>
|
||||
Object.fromEntries(
|
||||
readdirSync(join(root, dir)).sort().map(file => [parse(file).name, readFileSync(join(root, dir, file), encoding)]),
|
||||
);
|
||||
|
||||
const out = join(import.meta.dirname, '..', 'src', 'utils', 'readerAssets', 'embedded.generated.ts');
|
||||
writeFileSync(
|
||||
out,
|
||||
'// Generated by scripts/embed-reader-assets.mjs from assets/reader. Do not edit.\n' +
|
||||
`export const css: Record<string, string> = ${JSON.stringify(read('css', 'utf8'))};\n` +
|
||||
`export const js: Record<string, string> = ${JSON.stringify(read('js', 'utf8'))};\n` +
|
||||
`export const fonts: Record<string, string> = ${JSON.stringify(read('fonts', 'base64'))};\n`,
|
||||
);
|
||||
console.log(`reader assets embedded: ${out}`);
|
||||
@@ -1,13 +1,17 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, View, Text, StatusBar } from 'react-native';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StyleSheet, View, Text, StatusBar, ScrollView } from 'react-native';
|
||||
import ErrorBoundary from 'react-native-error-boundary';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import DeviceInfo from 'react-native-device-info';
|
||||
import { BUILD_TYPE, GIT_HASH } from '@env';
|
||||
import { version } from '../../../package.json';
|
||||
import { getString } from '@i18n/translations';
|
||||
import { getErrorChainMessages } from '@utils/error';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { Button, List } from '@components';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { restartApplication, shareCrashLogs } from '@services/crashLogs';
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error: Error;
|
||||
@@ -19,6 +23,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
|
||||
resetError,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const fallbackGetString = (
|
||||
key: Parameters<typeof getString>[0],
|
||||
@@ -33,23 +38,53 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
|
||||
};
|
||||
|
||||
const chainMessages = useMemo(() => getErrorChainMessages(error), [error]);
|
||||
const versionDetails = [
|
||||
`${fallbackGetString(
|
||||
'aboutScreen.version',
|
||||
'Version',
|
||||
)}: ${version} (${DeviceInfo.getBuildNumber()})`,
|
||||
BUILD_TYPE || 'Custom build',
|
||||
GIT_HASH,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
const handleCopyStackTrace = async () => {
|
||||
const copyStackTrace = async () => {
|
||||
try {
|
||||
const message = chainMessages.join('\n\nCaused by: ');
|
||||
await Clipboard.setStringAsync(`${message}\n\n${error.stack}`);
|
||||
showToast(
|
||||
fallbackGetString(
|
||||
'common.copiedToClipboard',
|
||||
'Copied to clipboard: Stack trace',
|
||||
{ name: 'Stack trace' },
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
// clipboard failure is non-critical
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShareCrashLogs = async () => {
|
||||
setIsSharing(true);
|
||||
try {
|
||||
await shareCrashLogs(error);
|
||||
} catch {
|
||||
const copiedStackTrace = await copyStackTrace();
|
||||
showToast(
|
||||
copiedStackTrace
|
||||
? fallbackGetString(
|
||||
'errorBoundary.shareCrashLogsFailed',
|
||||
'Could not share crash logs. The stack trace was copied instead.',
|
||||
)
|
||||
: fallbackGetString(
|
||||
'errorBoundary.shareCrashLogsFailedWithoutCopy',
|
||||
'Could not share crash logs or copy the stack trace.',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = () => {
|
||||
restartApplication(resetError).catch(resetError);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[styles.mainCtn, { backgroundColor: theme.background }]}
|
||||
@@ -65,41 +100,49 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
|
||||
<Text style={[styles.errorDesc, { color: theme.onSurface }]}>
|
||||
{fallbackGetString(
|
||||
'errorBoundary.description',
|
||||
'The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.',
|
||||
'The application ran into an unexpected error. Please share the crash logs in our Discord support channel.',
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.versionDetails, { color: theme.onSurfaceVariant }]}
|
||||
>
|
||||
{versionDetails}
|
||||
</Text>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.errorCtn,
|
||||
{
|
||||
backgroundColor: theme.surfaceVariant,
|
||||
color: theme.onSurfaceVariant,
|
||||
},
|
||||
]}
|
||||
numberOfLines={20}
|
||||
contentContainerStyle={styles.errorContent}
|
||||
>
|
||||
{`${chainMessages.join('\n\nCaused by: ')}\n\n${error.stack}`}
|
||||
</Text>
|
||||
<Text style={[styles.errorText, { color: theme.onSurfaceVariant }]}>
|
||||
{`${chainMessages.join('\n\nCaused by: ')}\n\n${error.stack}`}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<List.Divider theme={theme} />
|
||||
<Button
|
||||
onPress={handleCopyStackTrace}
|
||||
title={fallbackGetString(
|
||||
'errorBoundary.copyStackTrace',
|
||||
'Copy stack trace',
|
||||
)}
|
||||
style={styles.copyButtonCtn}
|
||||
mode="outlined"
|
||||
/>
|
||||
<Button
|
||||
onPress={resetError}
|
||||
title={fallbackGetString(
|
||||
'errorBoundary.restart',
|
||||
'Restart the application',
|
||||
)}
|
||||
style={styles.buttonCtn}
|
||||
mode="contained"
|
||||
/>
|
||||
<View style={styles.actionsCtn}>
|
||||
<Button
|
||||
disabled={isSharing}
|
||||
loading={isSharing}
|
||||
onPress={handleShareCrashLogs}
|
||||
title={fallbackGetString(
|
||||
'errorBoundary.shareCrashLogs',
|
||||
'Share crash logs',
|
||||
)}
|
||||
mode="outlined"
|
||||
/>
|
||||
<Button
|
||||
onPress={handleRestart}
|
||||
title={fallbackGetString(
|
||||
'errorBoundary.restart',
|
||||
'Restart the application',
|
||||
)}
|
||||
mode="contained"
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -117,35 +160,42 @@ const AppErrorBoundary: React.FC<AppErrorBoundaryProps> = ({ children }) => {
|
||||
export default AppErrorBoundary;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
buttonCtn: {
|
||||
margin: 16,
|
||||
marginBottom: 32,
|
||||
},
|
||||
copyButtonCtn: {
|
||||
margin: 16,
|
||||
marginBottom: 8,
|
||||
actionsCtn: {
|
||||
gap: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
errorCtn: {
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
lineHeight: 20,
|
||||
marginVertical: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorContent: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
errorText: {
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorDesc: {
|
||||
lineHeight: 20,
|
||||
marginVertical: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
errorInfoCtn: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
paddingTop: 32,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 20,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
versionDetails: {
|
||||
lineHeight: 24,
|
||||
marginVertical: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
mainCtn: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { PropsWithChildren, useEffect, useMemo, useState } from 'react';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
|
||||
import { getPendingCrash } from '@services/crashLogs';
|
||||
import { ErrorFallback } from './AppErrorBoundary';
|
||||
|
||||
const CrashRecoveryGate = ({ children }: PropsWithChildren) => {
|
||||
const [pendingCrash, setPendingCrash] = useState<string | null>();
|
||||
|
||||
useEffect(() => {
|
||||
getPendingCrash()
|
||||
.then(crash => {
|
||||
setPendingCrash(crash);
|
||||
if (crash) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
})
|
||||
.catch(() => setPendingCrash(null));
|
||||
}, []);
|
||||
|
||||
const crashError = useMemo(() => {
|
||||
if (!pendingCrash) return null;
|
||||
const error = new Error('LNReader stopped unexpectedly on its last run.');
|
||||
error.stack = pendingCrash;
|
||||
return error;
|
||||
}, [pendingCrash]);
|
||||
|
||||
if (pendingCrash === undefined) return null;
|
||||
if (crashError) {
|
||||
return <ErrorFallback error={crashError} resetError={() => null} />;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export default CrashRecoveryGate;
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@test-utils';
|
||||
import { ErrorFallback } from '../AppErrorBoundary';
|
||||
import { version } from '../../../../package.json';
|
||||
|
||||
const mockShareCrashLogs = jest.fn();
|
||||
const mockRestartApplication = jest.fn();
|
||||
|
||||
const mockTheme = {
|
||||
background: '#ffffff',
|
||||
onSurface: '#000000',
|
||||
onSurfaceVariant: '#333333',
|
||||
surfaceVariant: '#eeeeee',
|
||||
outlineVariant: '#cccccc',
|
||||
primary: '#0061a4',
|
||||
};
|
||||
|
||||
jest.mock('@hooks/persisted/useTheme', () => ({
|
||||
ThemeProvider: ({ children }: { children: ReactNode }) => children,
|
||||
useTheme: () => mockTheme,
|
||||
}));
|
||||
|
||||
jest.mock('@hooks/persisted', () => ({
|
||||
useTheme: () => mockTheme,
|
||||
}));
|
||||
|
||||
jest.mock('react-native-device-info', () => ({
|
||||
__esModule: true,
|
||||
default: { getBuildNumber: () => '42' },
|
||||
}));
|
||||
|
||||
jest.mock('@env', () => ({ BUILD_TYPE: 'Debug', GIT_HASH: 'abc123' }));
|
||||
|
||||
jest.mock('@screens/novel/NovelContext', () => ({
|
||||
NovelContextProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => {
|
||||
const ReactModule = require('react');
|
||||
const { View } = jest.requireActual('react-native');
|
||||
const frame = { height: 800, width: 400, x: 0, y: 0 };
|
||||
const insets = { bottom: 0, left: 0, right: 0, top: 0 };
|
||||
return {
|
||||
SafeAreaFrameContext: ReactModule.createContext(frame),
|
||||
SafeAreaInsetsContext: ReactModule.createContext(insets),
|
||||
SafeAreaProvider: ({ children }: { children: ReactNode }) => children,
|
||||
SafeAreaView: ({ children, ...props }: { children: ReactNode }) =>
|
||||
ReactModule.createElement(View, props, children),
|
||||
initialWindowMetrics: { frame, insets },
|
||||
useSafeAreaFrame: () => frame,
|
||||
useSafeAreaInsets: () => insets,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@services/crashLogs', () => ({
|
||||
shareCrashLogs: (error: Error) => mockShareCrashLogs(error),
|
||||
restartApplication: (fallback: () => void) =>
|
||||
mockRestartApplication(fallback),
|
||||
}));
|
||||
|
||||
describe('ErrorFallback', () => {
|
||||
beforeEach(() => {
|
||||
mockShareCrashLogs.mockResolvedValue(undefined);
|
||||
mockRestartApplication.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shares a diagnostic report for the displayed error', async () => {
|
||||
const error = new Error('boom');
|
||||
render(<ErrorFallback error={error} resetError={jest.fn()} />);
|
||||
|
||||
fireEvent.press(screen.getByText('Share crash logs'));
|
||||
|
||||
await waitFor(() => expect(mockShareCrashLogs).toHaveBeenCalledWith(error));
|
||||
});
|
||||
|
||||
it('shows version and build details on the error screen', () => {
|
||||
render(<ErrorFallback error={new Error('boom')} resetError={jest.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(`Version: ${version} (42) · Debug · abc123`),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses the native restart flow', async () => {
|
||||
const resetError = jest.fn();
|
||||
render(<ErrorFallback error={new Error('boom')} resetError={resetError} />);
|
||||
|
||||
fireEvent.press(screen.getByText('Restart the application'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockRestartApplication).toHaveBeenCalledWith(resetError),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -93,23 +93,32 @@ function CustomBottomTabBar({
|
||||
style={styles.pressable}
|
||||
>
|
||||
{/* Icon */}
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{
|
||||
transitionProperty: ['width', 'backgroundColor'],
|
||||
transitionDuration: 250,
|
||||
transitionTimingFunction: 'ease-in-out',
|
||||
marginBottom: showLabel ? 4 : 20,
|
||||
width: isFocused ? 64 : 32,
|
||||
backgroundColor: isFocused
|
||||
? theme.primaryContainer
|
||||
: transparentBg,
|
||||
},
|
||||
{ marginBottom: showLabel ? 4 : 20 },
|
||||
]}
|
||||
>
|
||||
{/* The indicator is absolutely positioned and animated with a
|
||||
transform so that growing it never re-runs layout, which
|
||||
would otherwise make the icon and label tremble sideways as
|
||||
their rounded pixel positions shift on every frame. */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.indicator,
|
||||
{
|
||||
transitionProperty: ['transform', 'backgroundColor'],
|
||||
transitionDuration: 250,
|
||||
transitionTimingFunction: 'ease-in-out',
|
||||
transform: [{ scaleX: isFocused ? 1 : 0.5 }],
|
||||
backgroundColor: isFocused
|
||||
? theme.primaryContainer
|
||||
: transparentBg,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{renderIcon({ color: iconColor, route })}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Label */}
|
||||
{showLabel ? (
|
||||
@@ -155,7 +164,15 @@ const styles = StyleSheet.create({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 64,
|
||||
height: 32,
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 16,
|
||||
},
|
||||
label: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
UseLibraryReturnType,
|
||||
} from '@screens/library/hooks/useLibrary';
|
||||
import { useLibrarySettings } from '@hooks/persisted';
|
||||
import SetCategoriesModal from '@screens/novel/components/SetCategoriesModal';
|
||||
|
||||
// type Library = Category & { novels: LibraryNovelInfo[] };
|
||||
|
||||
@@ -11,6 +12,8 @@ type LibraryContextType = UseLibraryReturnType & {
|
||||
settings: ReturnType<typeof useLibrarySettings>;
|
||||
};
|
||||
|
||||
const EMPTY_NOVEL_IDS: number[] = [];
|
||||
|
||||
const defaultValue = {} as LibraryContextType;
|
||||
const LibraryContext = createContext<LibraryContextType>(defaultValue);
|
||||
|
||||
@@ -21,11 +24,27 @@ export function LibraryContextProvider({
|
||||
}) {
|
||||
const useLibraryParams = useLibrary();
|
||||
const settings = useLibrarySettings();
|
||||
const {
|
||||
pendingLibraryAddition,
|
||||
cancelPendingLibraryAddition,
|
||||
confirmPendingLibraryAddition,
|
||||
} = useLibraryParams;
|
||||
|
||||
return (
|
||||
<LibraryContext.Provider value={{ ...useLibraryParams, settings }}>
|
||||
{children}
|
||||
</LibraryContext.Provider>
|
||||
<>
|
||||
<LibraryContext.Provider value={{ ...useLibraryParams, settings }}>
|
||||
{children}
|
||||
</LibraryContext.Provider>
|
||||
{pendingLibraryAddition ? (
|
||||
<SetCategoriesModal
|
||||
novelIds={EMPTY_NOVEL_IDS}
|
||||
initialCategoryIds={pendingLibraryAddition.initialCategoryIds}
|
||||
closeModal={cancelPendingLibraryAddition}
|
||||
onSubmit={confirmPendingLibraryAddition}
|
||||
visible
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ const defaultLibraryContext = {
|
||||
refreshCategories: jest.fn().mockResolvedValue(undefined),
|
||||
setLibrary: jest.fn(),
|
||||
novelInLibrary: jest.fn(() => false),
|
||||
switchNovelToLibrary: jest.fn().mockResolvedValue(undefined),
|
||||
switchNovelToLibrary: jest.fn().mockResolvedValue(true),
|
||||
pendingLibraryAddition: undefined,
|
||||
cancelPendingLibraryAddition: jest.fn(),
|
||||
confirmPendingLibraryAddition: jest.fn().mockResolvedValue(undefined),
|
||||
refetchLibrary: jest.fn(),
|
||||
setLibrarySearchText: jest.fn(),
|
||||
settings: {
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ListViewProps {
|
||||
onPress: () => void;
|
||||
isSelected?: boolean;
|
||||
onLongPress?: () => void;
|
||||
continueReadingButton?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ListView = ({
|
||||
@@ -27,6 +28,7 @@ const ListView = ({
|
||||
onPress,
|
||||
isSelected,
|
||||
onLongPress,
|
||||
continueReadingButton,
|
||||
}: ListViewProps) => {
|
||||
const fadedImage = { opacity: inLibraryBadge ? 0.5 : 1 };
|
||||
return (
|
||||
@@ -58,6 +60,7 @@ const ListView = ({
|
||||
{unreadBadge}
|
||||
{inLibraryBadge}
|
||||
</View>
|
||||
{continueReadingButton}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Color from 'color';
|
||||
import ListView from './ListView';
|
||||
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
@@ -19,6 +20,7 @@ import { getString } from '@i18n/translations';
|
||||
import SourceScreenSkeletonLoading from '@screens/browse/loadingAnimation/SourceScreenSkeletonLoading';
|
||||
import NovelCoverImage from './NovelCoverImage';
|
||||
import { useNovelCoverLayout } from './NovelCoverLayoutContext';
|
||||
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
|
||||
|
||||
interface UnreadBadgeProps {
|
||||
showDownloadBadges: boolean;
|
||||
@@ -61,6 +63,7 @@ interface INovelCover<TNovel> {
|
||||
selectedNovelIds?: number[];
|
||||
globalSearch?: boolean;
|
||||
imageRequestInit?: ImageRequestInit;
|
||||
onContinueReading?: () => void;
|
||||
}
|
||||
|
||||
function isFromDB(
|
||||
@@ -84,6 +87,7 @@ function NovelCover<
|
||||
globalSearch,
|
||||
selectedNovelIds,
|
||||
imageRequestInit,
|
||||
onContinueReading,
|
||||
}: INovelCover<TNovel>) {
|
||||
const selectionActive =
|
||||
hasSelection ?? (selectedNovelIds != null && selectedNovelIds.length > 0);
|
||||
@@ -108,6 +112,25 @@ function NovelCover<
|
||||
[imageRequestInit],
|
||||
);
|
||||
|
||||
const continueReadingButton =
|
||||
onContinueReading && !selectionActive ? (
|
||||
<Pressable
|
||||
accessibilityLabel={getString('novelScreen.continueReading')}
|
||||
accessibilityRole="button"
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
onPress={event => {
|
||||
event.stopPropagation();
|
||||
onContinueReading();
|
||||
}}
|
||||
style={[
|
||||
styles.continueReadingButton,
|
||||
{ backgroundColor: Color(theme.primary).alpha(0.9).string() },
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons name="play" size={20} color={theme.onPrimary} />
|
||||
</Pressable>
|
||||
) : null;
|
||||
|
||||
if (item.completeRow) {
|
||||
if (!addSkeletonLoading) {
|
||||
return <></>;
|
||||
@@ -197,6 +220,13 @@ function NovelCover<
|
||||
width={coverWidth}
|
||||
/>
|
||||
) : null}
|
||||
{continueReadingButton ? (
|
||||
<View
|
||||
style={[styles.continueReadingOverlay, { top: coverHeight - 40 }]}
|
||||
>
|
||||
{continueReadingButton}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
@@ -227,6 +257,7 @@ function NovelCover<
|
||||
onPress={selectionActive ? selectNovel : onPress}
|
||||
onLongPress={selectNovel}
|
||||
isSelected={isSelected}
|
||||
continueReadingButton={continueReadingButton}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -373,6 +404,18 @@ const styles = StyleSheet.create({
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
},
|
||||
continueReadingButton: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
height: 36,
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
width: 36,
|
||||
},
|
||||
continueReadingOverlay: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
},
|
||||
downloadBadge: {
|
||||
borderBottomLeftRadius: 4,
|
||||
borderTopLeftRadius: 4,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import './mocks';
|
||||
import { Fragment, type ReactElement } from 'react';
|
||||
import { fireEvent, render, screen } from '@test-utils';
|
||||
import NovelCover from '../NovelCover';
|
||||
import { NovelCoverLayoutProvider } from '../NovelCoverLayoutContext';
|
||||
import { DisplayModes } from '@screens/library/constants/constants';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { getString } from '@i18n/translations';
|
||||
import LibraryNovelItem from '@screens/library/components/LibraryNovelItem';
|
||||
import type { History } from '@database/types';
|
||||
|
||||
jest.mock(
|
||||
'@screens/browse/loadingAnimation/SourceScreenSkeletonLoading',
|
||||
() => 'SourceScreenSkeletonLoading',
|
||||
);
|
||||
jest.mock('@hooks', () => ({ useDeviceOrientation: () => 'portrait' }));
|
||||
|
||||
const novel = {
|
||||
id: 1,
|
||||
name: 'Example Novel',
|
||||
path: '/example',
|
||||
pluginId: 'example',
|
||||
};
|
||||
|
||||
const onPress = jest.fn();
|
||||
const onContinueReading = jest.fn();
|
||||
const lastReadChapter = {
|
||||
id: 7,
|
||||
novelId: novel.id,
|
||||
name: 'Chapter 7',
|
||||
} as History;
|
||||
const renderCover = (element: ReactElement) =>
|
||||
render(element, { wrapper: Fragment });
|
||||
|
||||
const Cover = ({
|
||||
displayMode,
|
||||
hasSelection = false,
|
||||
canContinue = true,
|
||||
}: {
|
||||
displayMode: DisplayModes;
|
||||
hasSelection?: boolean;
|
||||
canContinue?: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<NovelCoverLayoutProvider
|
||||
value={{
|
||||
coverHeight: 160,
|
||||
displayMode,
|
||||
numColumns: displayMode === DisplayModes.List ? 1 : 3,
|
||||
showDownloadBadges: false,
|
||||
showUnreadBadges: false,
|
||||
}}
|
||||
>
|
||||
<NovelCover
|
||||
item={novel}
|
||||
theme={theme}
|
||||
isSelected={false}
|
||||
hasSelection={hasSelection}
|
||||
onPress={onPress}
|
||||
onLongPress={jest.fn()}
|
||||
onContinueReading={canContinue ? onContinueReading : undefined}
|
||||
libraryStatus={false}
|
||||
/>
|
||||
</NovelCoverLayoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryItem = ({
|
||||
showContinueReadingButton,
|
||||
}: {
|
||||
showContinueReadingButton: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<NovelCoverLayoutProvider
|
||||
value={{
|
||||
coverHeight: 160,
|
||||
displayMode: DisplayModes.Comfortable,
|
||||
numColumns: 3,
|
||||
showDownloadBadges: false,
|
||||
showUnreadBadges: false,
|
||||
}}
|
||||
>
|
||||
<LibraryNovelItem
|
||||
item={novel}
|
||||
theme={theme}
|
||||
isSelected={false}
|
||||
hasSelection={false}
|
||||
onSelect={jest.fn()}
|
||||
onNavigate={jest.fn()}
|
||||
onContinueReading={onContinueReading}
|
||||
lastReadChapter={lastReadChapter}
|
||||
showContinueReadingButton={showContinueReadingButton}
|
||||
imageRequestInit={undefined}
|
||||
/>
|
||||
</NovelCoverLayoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('NovelCover continue reading', () => {
|
||||
beforeEach(() => {
|
||||
onPress.mockClear();
|
||||
onContinueReading.mockClear();
|
||||
});
|
||||
|
||||
it.each([DisplayModes.Compact, DisplayModes.Comfortable, DisplayModes.List])(
|
||||
'opens the saved chapter from %s layout',
|
||||
displayMode => {
|
||||
renderCover(<Cover displayMode={displayMode} />);
|
||||
|
||||
fireEvent.press(
|
||||
screen.getByRole('button', {
|
||||
name: getString('novelScreen.continueReading'),
|
||||
}),
|
||||
{ stopPropagation: jest.fn() },
|
||||
);
|
||||
|
||||
expect(onContinueReading).toHaveBeenCalledTimes(1);
|
||||
expect(onPress).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('hides the shortcut during selection and when there is no reading history', () => {
|
||||
const { rerender } = renderCover(
|
||||
<Cover displayMode={DisplayModes.Comfortable} hasSelection />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: getString('novelScreen.continueReading'),
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
rerender(
|
||||
<Cover displayMode={DisplayModes.Comfortable} canContinue={false} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: getString('novelScreen.continueReading'),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('respects the library display setting', () => {
|
||||
const { rerender } = renderCover(
|
||||
<LibraryItem showContinueReadingButton={false} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: getString('novelScreen.continueReading'),
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
rerender(<LibraryItem showContinueReadingButton />);
|
||||
fireEvent.press(
|
||||
screen.getByRole('button', {
|
||||
name: getString('novelScreen.continueReading'),
|
||||
}),
|
||||
{ stopPropagation: jest.fn() },
|
||||
);
|
||||
expect(onContinueReading).toHaveBeenCalledWith(novel, lastReadChapter);
|
||||
});
|
||||
});
|
||||
@@ -289,6 +289,27 @@ describe('runDatabaseBootstrap', () => {
|
||||
sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('repairs only an empty default category name', () => {
|
||||
const sqlite = open({ name: ':memory:' });
|
||||
try {
|
||||
createSchema(sqlite);
|
||||
sqlite.executeSync(
|
||||
"INSERT INTO Category (id, name, sort) VALUES (1, ' ', 1), (2, 'Legacy user category', 2)",
|
||||
);
|
||||
|
||||
runDatabaseBootstrap(createExecutor(sqlite));
|
||||
|
||||
expect(
|
||||
sqlite.executeSync('SELECT id, name FROM Category ORDER BY id').rows,
|
||||
).toEqual([
|
||||
{ id: 1, name: 'categories.default' },
|
||||
{ id: 2, name: 'Legacy user category' },
|
||||
]);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('production migrations', () => {
|
||||
|
||||
+7
-1
@@ -8,7 +8,11 @@ import { migrate } from 'drizzle-orm/op-sqlite/migrator';
|
||||
import migrations from '../../drizzle/migrations';
|
||||
import { createDbManager } from './manager/manager';
|
||||
import { open } from '@op-engineering/op-sqlite';
|
||||
import { createCategoryDefaultQuery } from './queryStrings/populate';
|
||||
import {
|
||||
createCategoryDefaultQuery,
|
||||
repairDefaultCategoryNameQuery,
|
||||
createDefaultRepositoryQuery,
|
||||
} from './queryStrings/populate';
|
||||
import {
|
||||
createCategoryTriggerQuery,
|
||||
createNovelTriggerQueryDelete,
|
||||
@@ -332,6 +336,8 @@ const setPragmas = (executor: SqlExecutor) => {
|
||||
const populateDatabase = (executor: SqlExecutor) => {
|
||||
console.log('Populating database');
|
||||
executor.executeSync(createCategoryDefaultQuery);
|
||||
executor.executeSync(repairDefaultCategoryNameQuery);
|
||||
executor.executeSync(createDefaultRepositoryQuery);
|
||||
};
|
||||
|
||||
const createDbTriggers = (executor: SqlExecutor) => {
|
||||
|
||||
@@ -8,6 +8,17 @@ import {
|
||||
novelCategorySchema,
|
||||
type CategoryRow,
|
||||
} from '@database/schema';
|
||||
import { BUILT_IN_CATEGORY_IDS } from '@database/constants';
|
||||
|
||||
const DEFAULT_CATEGORY_ID = BUILT_IN_CATEGORY_IDS.default;
|
||||
|
||||
const normalizeCategoryName = (categoryName: string): string => {
|
||||
const normalizedName = categoryName.trim();
|
||||
if (!normalizedName) {
|
||||
throw new Error('Category name cannot be empty');
|
||||
}
|
||||
return normalizedName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all categories with their novel IDs using Drizzle ORM
|
||||
@@ -90,11 +101,13 @@ export const getCategoriesWithCount = async (
|
||||
export const createCategory = async (
|
||||
categoryName: string,
|
||||
): Promise<CategoryRow> => {
|
||||
const normalizedName = normalizeCategoryName(categoryName);
|
||||
|
||||
return await dbManager.write(async tx => {
|
||||
const categoryCount = await tx.$count(categorySchema);
|
||||
const row = await tx
|
||||
.insert(categorySchema)
|
||||
.values({ name: categoryName, sort: categoryCount + 1 })
|
||||
.values({ name: normalizedName, sort: categoryCount + 1 })
|
||||
.returning()
|
||||
.get();
|
||||
return row;
|
||||
@@ -152,10 +165,16 @@ export const updateCategory = async (
|
||||
categoryId: number,
|
||||
categoryName: string,
|
||||
): Promise<void> => {
|
||||
if (categoryId === DEFAULT_CATEGORY_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedName = normalizeCategoryName(categoryName);
|
||||
|
||||
await dbManager.write(async tx => {
|
||||
await tx
|
||||
.update(categorySchema)
|
||||
.set({ name: categoryName })
|
||||
.set({ name: normalizedName })
|
||||
.where(eq(categorySchema.id, categoryId))
|
||||
.run();
|
||||
});
|
||||
@@ -165,11 +184,16 @@ export const updateCategory = async (
|
||||
* Check if a category name already exists using Drizzle ORM
|
||||
*/
|
||||
export const isCategoryNameDuplicate = (categoryName: string): boolean => {
|
||||
const normalizedName = categoryName.trim();
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = dbManager.getSync(
|
||||
dbManager
|
||||
.select({ id: categorySchema.id })
|
||||
.from(categorySchema)
|
||||
.where(eq(categorySchema.name, categoryName)),
|
||||
.where(eq(categorySchema.name, normalizedName)),
|
||||
);
|
||||
|
||||
return !!result;
|
||||
@@ -209,39 +233,57 @@ export const getAllNovelCategories = async (): Promise<NovelCategory[]> => {
|
||||
*/
|
||||
export const _restoreCategory = async (
|
||||
category: BackupCategory,
|
||||
): Promise<void> => {
|
||||
await dbManager.write(async tx => {
|
||||
// Delete existing category with same id or sort
|
||||
await tx
|
||||
.delete(categorySchema)
|
||||
.where(
|
||||
sql`${categorySchema.id} = ${category.id} OR ${categorySchema.sort} = ${category.sort}`,
|
||||
)
|
||||
.run();
|
||||
novelIdMap?: ReadonlyMap<number, number>,
|
||||
): Promise<number> => {
|
||||
return dbManager.write(async tx => {
|
||||
const isDefaultCategory = category.id === DEFAULT_CATEGORY_ID;
|
||||
const normalizedName = isDefaultCategory
|
||||
? undefined
|
||||
: normalizeCategoryName(category.name);
|
||||
const existingByName = normalizedName
|
||||
? await tx
|
||||
.select({ id: categorySchema.id })
|
||||
.from(categorySchema)
|
||||
.where(eq(categorySchema.name, normalizedName))
|
||||
.get()
|
||||
: undefined;
|
||||
const existingDefault =
|
||||
existingByName || !isDefaultCategory
|
||||
? undefined
|
||||
: await tx
|
||||
.select({ id: categorySchema.id })
|
||||
.from(categorySchema)
|
||||
.where(eq(categorySchema.id, DEFAULT_CATEGORY_ID))
|
||||
.get();
|
||||
|
||||
// Insert the category
|
||||
await tx
|
||||
.insert(categorySchema)
|
||||
.values({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
sort: category.sort,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
let categoryId = existingByName?.id ?? existingDefault?.id;
|
||||
if (!categoryId) {
|
||||
const restoredCategory = await tx
|
||||
.insert(categorySchema)
|
||||
.values({
|
||||
name: normalizedName ?? getString('categories.default'),
|
||||
sort: category.sort,
|
||||
})
|
||||
.returning({ id: categorySchema.id })
|
||||
.get();
|
||||
categoryId = restoredCategory.id;
|
||||
}
|
||||
|
||||
// Insert novel-category associations
|
||||
if (category.novelIds && category.novelIds.length > 0) {
|
||||
for (const novelId of category.novelIds) {
|
||||
for (const backupNovelId of category.novelIds) {
|
||||
const novelId = novelIdMap?.get(backupNovelId) ?? backupNovelId;
|
||||
await tx
|
||||
.insert(novelCategorySchema)
|
||||
.values({
|
||||
categoryId: category.id,
|
||||
categoryId,
|
||||
novelId: novelId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
return categoryId;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -785,7 +785,9 @@ export const getPrevChapter = async (
|
||||
.orderBy(
|
||||
desc(castInt(chapterSchema.page)),
|
||||
desc(castInt(chapterSchema.position)),
|
||||
desc(chapterSchema.id),
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
};
|
||||
|
||||
@@ -814,7 +816,9 @@ export const getNextChapter = async (
|
||||
.orderBy(
|
||||
asc(castInt(chapterSchema.page)),
|
||||
asc(castInt(chapterSchema.position)),
|
||||
asc(chapterSchema.id),
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import { insertChapters } from './ChapterQueries';
|
||||
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { getString } from '@i18n/translations';
|
||||
import { BackupNovel, DBNovelInfo, NovelInfo } from '../types';
|
||||
import {
|
||||
BackupNovel,
|
||||
DBNovelInfo,
|
||||
NovelInfo,
|
||||
type RestoredNovelMapping,
|
||||
} from '../types';
|
||||
import { SourceNovel } from '@plugins/types';
|
||||
import { NOVEL_STORAGE } from '@utils/Storages';
|
||||
import { downloadFile } from '@plugins/helpers/fetch';
|
||||
@@ -23,7 +28,36 @@ import { getLibraryDefaultCategoryId } from '@hooks/persisted/useSettings';
|
||||
import NativeFile from '@modules/native-file';
|
||||
import { BUILT_IN_CATEGORY_IDS } from '@database/constants';
|
||||
|
||||
const getCategoryForNewNovel = async (tx: TransactionParameter) => {
|
||||
const getBuiltInDefaultCategory = async (tx: TransactionParameter) => {
|
||||
const defaultCategory = await tx
|
||||
.select({ id: categorySchema.id })
|
||||
.from(categorySchema)
|
||||
.where(eq(categorySchema.id, BUILT_IN_CATEGORY_IDS.default))
|
||||
.get();
|
||||
|
||||
return defaultCategory ? [defaultCategory] : [];
|
||||
};
|
||||
|
||||
const getCategoriesForNewNovel = async (
|
||||
tx: TransactionParameter,
|
||||
categoryIds?: number[],
|
||||
) => {
|
||||
if (categoryIds !== undefined) {
|
||||
if (categoryIds.length) {
|
||||
const selectedCategories = await tx
|
||||
.select({ id: categorySchema.id })
|
||||
.from(categorySchema)
|
||||
.where(inArray(categorySchema.id, categoryIds))
|
||||
.all();
|
||||
|
||||
if (selectedCategories.length) {
|
||||
return selectedCategories;
|
||||
}
|
||||
}
|
||||
|
||||
return getBuiltInDefaultCategory(tx);
|
||||
}
|
||||
|
||||
const preferredCategoryId = getLibraryDefaultCategoryId();
|
||||
|
||||
if (preferredCategoryId) {
|
||||
@@ -34,17 +68,16 @@ const getCategoryForNewNovel = async (tx: TransactionParameter) => {
|
||||
.get();
|
||||
|
||||
if (preferredCategory) {
|
||||
return preferredCategory;
|
||||
return [preferredCategory];
|
||||
}
|
||||
}
|
||||
|
||||
return tx
|
||||
.select({ id: categorySchema.id })
|
||||
.from(categorySchema)
|
||||
.where(eq(categorySchema.id, BUILT_IN_CATEGORY_IDS.default))
|
||||
.get();
|
||||
return getBuiltInDefaultCategory(tx);
|
||||
};
|
||||
|
||||
const getCategoryForNewNovel = async (tx: TransactionParameter) =>
|
||||
(await getCategoriesForNewNovel(tx))[0];
|
||||
|
||||
/**
|
||||
* Inserts a novel and its chapters into the database using Drizzle ORM.
|
||||
* Also handles downloading the novel cover if available.
|
||||
@@ -138,6 +171,7 @@ export const getNovelByPath = (
|
||||
export const switchNovelToLibraryQuery = async (
|
||||
novelPath: string,
|
||||
pluginId: string,
|
||||
categoryIds?: number[],
|
||||
): Promise<NovelInfo | undefined> => {
|
||||
const novel = await getNovelByPath(novelPath, pluginId);
|
||||
if (novel) {
|
||||
@@ -157,16 +191,18 @@ export const switchNovelToLibraryQuery = async (
|
||||
.run();
|
||||
showToast(getString('browseScreen.removeFromLibrary'));
|
||||
} else {
|
||||
// Add to library: add to default category
|
||||
const defaultCategory = await getCategoryForNewNovel(tx);
|
||||
// Add to library with the selected or configured default categories.
|
||||
const categories = await getCategoriesForNewNovel(tx, categoryIds);
|
||||
|
||||
if (defaultCategory) {
|
||||
if (categories.length) {
|
||||
await tx
|
||||
.insert(novelCategorySchema)
|
||||
.values({
|
||||
novelId: novel.id,
|
||||
categoryId: defaultCategory.id,
|
||||
})
|
||||
.values(
|
||||
categories.map((category: { id: number }) => ({
|
||||
novelId: novel.id,
|
||||
categoryId: category.id,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -195,15 +231,17 @@ export const switchNovelToLibraryQuery = async (
|
||||
.where(eq(novelSchema.id, novelId))
|
||||
.run();
|
||||
|
||||
const defaultCategory = await getCategoryForNewNovel(tx);
|
||||
const categories = await getCategoriesForNewNovel(tx, categoryIds);
|
||||
|
||||
if (defaultCategory) {
|
||||
if (categories.length) {
|
||||
await tx
|
||||
.insert(novelCategorySchema)
|
||||
.values({
|
||||
novelId: novelId,
|
||||
categoryId: defaultCategory.id,
|
||||
})
|
||||
.values(
|
||||
categories.map((category: { id: number }) => ({
|
||||
novelId: novelId,
|
||||
categoryId: category.id,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
});
|
||||
@@ -440,18 +478,13 @@ export const updateNovelCategories = async (
|
||||
/**
|
||||
* Restores novel and chapters from a backup object.
|
||||
*/
|
||||
export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => {
|
||||
const { chapters, ...novel } = backupNovel;
|
||||
await dbManager.write(async tx => {
|
||||
// Delete existing novel data
|
||||
await tx.delete(novelSchema).where(eq(novelSchema.id, novel.id)).run();
|
||||
await tx
|
||||
.delete(chapterSchema)
|
||||
.where(eq(chapterSchema.novelId, novel.id))
|
||||
.run();
|
||||
|
||||
// Restore novel
|
||||
await tx
|
||||
export const _restoreNovelAndChapters = async (
|
||||
backupNovel: BackupNovel,
|
||||
): Promise<RestoredNovelMapping> => {
|
||||
const { chapters, id: backupNovelId, ...novel } = backupNovel;
|
||||
return dbManager.write(async tx => {
|
||||
// Match novels by their stable source identity, not the database-local ID.
|
||||
const restoredNovel = await tx
|
||||
.insert(novelSchema)
|
||||
.values({
|
||||
...novel,
|
||||
@@ -459,15 +492,71 @@ export const _restoreNovelAndChapters = async (backupNovel: BackupNovel) => {
|
||||
chaptersDownloaded: 0,
|
||||
chaptersUnread: 0,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [novelSchema.path, novelSchema.pluginId],
|
||||
set: {
|
||||
...novel,
|
||||
totalChapters: 0,
|
||||
chaptersDownloaded: 0,
|
||||
chaptersUnread: 0,
|
||||
},
|
||||
})
|
||||
.returning({ id: novelSchema.id })
|
||||
.get();
|
||||
|
||||
if (novel.cover?.startsWith(`file://${NOVEL_STORAGE}/`)) {
|
||||
const cacheSuffix = novel.cover.match(/[?#].*$/)?.[0] ?? '';
|
||||
await tx
|
||||
.update(novelSchema)
|
||||
.set({
|
||||
cover: `file://${NOVEL_STORAGE}/${novel.pluginId}/${restoredNovel.id}/cover.png${cacheSuffix}`,
|
||||
})
|
||||
.where(eq(novelSchema.id, restoredNovel.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
await tx
|
||||
.delete(chapterSchema)
|
||||
.where(eq(chapterSchema.novelId, restoredNovel.id))
|
||||
.run();
|
||||
|
||||
const chapterMappings: RestoredNovelMapping['chapters'] = [];
|
||||
|
||||
// Restore chapters in batches
|
||||
if (chapters.length > 0) {
|
||||
const BATCH_SIZE = 100;
|
||||
for (let i = 0; i < chapters.length; i += BATCH_SIZE) {
|
||||
const batch = chapters.slice(i, i + BATCH_SIZE);
|
||||
await tx.insert(chapterSchema).values(batch).run();
|
||||
const restoredChapters = await tx
|
||||
.insert(chapterSchema)
|
||||
.values(
|
||||
batch.map(({ id: _chapterId, novelId: _novelId, ...chapter }) => ({
|
||||
...chapter,
|
||||
novelId: restoredNovel.id,
|
||||
})),
|
||||
)
|
||||
.returning({ id: chapterSchema.id, path: chapterSchema.path })
|
||||
.all();
|
||||
const restoredIdsByPath = new Map(
|
||||
restoredChapters.map(chapter => [chapter.path, chapter.id]),
|
||||
);
|
||||
for (const chapter of batch) {
|
||||
const restoredChapterId = restoredIdsByPath.get(chapter.path);
|
||||
if (restoredChapterId !== undefined) {
|
||||
chapterMappings.push({
|
||||
backupChapterId: chapter.id,
|
||||
restoredChapterId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pluginId: novel.pluginId,
|
||||
backupNovelId,
|
||||
restoredNovelId: restoredNovel.id,
|
||||
chapters: chapterMappings,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
deleteCategoryById,
|
||||
updateCategoryOrderInDb,
|
||||
getAllNovelCategories,
|
||||
_restoreCategory,
|
||||
} from '../CategoryQueries';
|
||||
import { showToast } from '@utils/showToast';
|
||||
|
||||
@@ -56,6 +57,89 @@ describe('CategoryQueries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('_restoreCategory', () => {
|
||||
it('preserves the default name and adds restored memberships', async () => {
|
||||
const testDb = getTestDb();
|
||||
const existingNovelId = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
});
|
||||
const restoredNovelId = await insertTestNovel(testDb, {
|
||||
inLibrary: true,
|
||||
});
|
||||
await insertTestNovelCategory(testDb, existingNovelId, 1);
|
||||
|
||||
await _restoreCategory({
|
||||
id: 1,
|
||||
name: ' ',
|
||||
sort: 1,
|
||||
novelIds: [restoredNovelId],
|
||||
});
|
||||
|
||||
const categories = await getCategoriesFromDb();
|
||||
expect(categories.find(category => category.id === 1)?.name).toBe(
|
||||
'Default',
|
||||
);
|
||||
const memberships = await getAllNovelCategories();
|
||||
expect(
|
||||
memberships
|
||||
.filter(membership => membership.categoryId === 1)
|
||||
.map(membership => membership.novelId),
|
||||
).toEqual(expect.arrayContaining([existingNovelId, restoredNovelId]));
|
||||
});
|
||||
|
||||
it('does not overwrite a custom category with a colliding backup ID', async () => {
|
||||
const testDb = getTestDb();
|
||||
const existingCategoryId = await insertTestCategory(testDb, {
|
||||
name: 'Existing Category',
|
||||
});
|
||||
|
||||
const restoredCategoryId = await _restoreCategory({
|
||||
id: existingCategoryId,
|
||||
name: 'Restored Category',
|
||||
sort: 3,
|
||||
novelIds: [],
|
||||
});
|
||||
|
||||
expect(restoredCategoryId).not.toBe(existingCategoryId);
|
||||
const categories = await getCategoriesFromDb();
|
||||
expect(
|
||||
categories.find(category => category.id === existingCategoryId)?.name,
|
||||
).toBe('Existing Category');
|
||||
expect(
|
||||
categories.find(category => category.id === restoredCategoryId)?.name,
|
||||
).toBe('Restored Category');
|
||||
});
|
||||
|
||||
it('does not treat a legacy category with ID 2 as the Local category', async () => {
|
||||
const restoredCategoryId = await _restoreCategory({
|
||||
id: 2,
|
||||
name: 'Legacy user category',
|
||||
sort: 2,
|
||||
novelIds: [],
|
||||
});
|
||||
|
||||
expect(restoredCategoryId).not.toBe(2);
|
||||
const categories = await getCategoriesFromDb();
|
||||
expect(categories.find(category => category.id === 2)?.name).toBe(
|
||||
'Local',
|
||||
);
|
||||
expect(
|
||||
categories.find(category => category.id === restoredCategoryId)?.name,
|
||||
).toBe('Legacy user category');
|
||||
});
|
||||
|
||||
it('rejects a restored user category with an empty name', async () => {
|
||||
await expect(
|
||||
_restoreCategory({
|
||||
id: 3,
|
||||
name: ' ',
|
||||
sort: 3,
|
||||
novelIds: [],
|
||||
}),
|
||||
).rejects.toThrow('Category name cannot be empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCategory', () => {
|
||||
it('should create a new category', async () => {
|
||||
const result = await createCategory('New Category');
|
||||
@@ -71,6 +155,18 @@ describe('CategoryQueries', () => {
|
||||
|
||||
expect(cat2.sort).toBeGreaterThan(cat1.sort!);
|
||||
});
|
||||
|
||||
it('trims category names', async () => {
|
||||
const category = await createCategory(' New Category ');
|
||||
|
||||
expect(category.name).toBe('New Category');
|
||||
});
|
||||
|
||||
it('rejects an empty category name', async () => {
|
||||
await expect(createCategory(' ')).rejects.toThrow(
|
||||
'Category name cannot be empty',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCategoryNameDuplicate', () => {
|
||||
@@ -103,6 +199,38 @@ describe('CategoryQueries', () => {
|
||||
const updated = categories.find(c => c.id === categoryId);
|
||||
expect(updated?.name).toBe('New Name');
|
||||
});
|
||||
|
||||
it('trims the updated category name', async () => {
|
||||
const categoryId = await insertTestCategory(getTestDb(), {
|
||||
name: 'Old Name',
|
||||
});
|
||||
|
||||
await updateCategory(categoryId, ' New Name ');
|
||||
|
||||
const categories = await getCategoriesFromDb();
|
||||
expect(
|
||||
categories.find(category => category.id === categoryId)?.name,
|
||||
).toBe('New Name');
|
||||
});
|
||||
|
||||
it('rejects an empty updated category name', async () => {
|
||||
const categoryId = await insertTestCategory(getTestDb(), {
|
||||
name: 'Old Name',
|
||||
});
|
||||
|
||||
await expect(updateCategory(categoryId, ' ')).rejects.toThrow(
|
||||
'Category name cannot be empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not rename the default category', async () => {
|
||||
await updateCategory(1, 'Renamed Default');
|
||||
|
||||
const categories = await getCategoriesFromDb();
|
||||
expect(categories.find(category => category.id === 1)?.name).toBe(
|
||||
'Default',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategoriesWithCount', () => {
|
||||
|
||||
@@ -1008,6 +1008,114 @@ describe('ChapterQueries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('chapter neighbors', () => {
|
||||
it('limits rows transferred by both neighbor lookups to one', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
for (let position = 0; position < 5; position++) {
|
||||
await insertTestChapter(testDb, novelId, { position });
|
||||
}
|
||||
const client = testDb.sqlite as typeof testDb.sqlite & {
|
||||
executeRawAsync: typeof testDb.sqlite.executeRaw;
|
||||
};
|
||||
const executeRaw = jest.spyOn(client, 'executeRawAsync');
|
||||
try {
|
||||
expect((await getNextChapter(novelId, 0, '1'))?.position).toBe(1);
|
||||
expect((await getPrevChapter(novelId, 4, '1'))?.position).toBe(3);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
for (const call of executeRaw.mock.calls) {
|
||||
expect(call[0]).toMatch(/limit \?/i);
|
||||
expect(call[1]?.at(-1)).toBe(1);
|
||||
}
|
||||
for (const result of executeRaw.mock.results) {
|
||||
expect(await result.value).toHaveLength(1);
|
||||
}
|
||||
} finally {
|
||||
executeRaw.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns no neighbor before the first or after the last chapter', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
await insertTestChapter(testDb, novelId, { page: '1', position: 0 });
|
||||
await insertTestChapter(testDb, novelId, { page: '2', position: 1 });
|
||||
|
||||
expect(await getPrevChapter(novelId, 0, '1')).toBeUndefined();
|
||||
expect(await getNextChapter(novelId, 1, '2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('breaks duplicate position ties by id in the navigation direction', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
const firstId = await insertTestChapter(testDb, novelId, { position: 1 });
|
||||
const secondId = await insertTestChapter(testDb, novelId, {
|
||||
position: 1,
|
||||
});
|
||||
|
||||
expect((await getNextChapter(novelId, 0, '1'))?.id).toBe(firstId);
|
||||
expect((await getPrevChapter(novelId, 2, '1'))?.id).toBe(secondId);
|
||||
expect(await getNextChapter(novelId, 1, '1')).toBeUndefined();
|
||||
expect(await getPrevChapter(novelId, 1, '1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves numeric-looking page comparisons and integer page ordering', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
const page1 = await insertTestChapter(testDb, novelId, { page: '1' });
|
||||
const page02 = await insertTestChapter(testDb, novelId, { page: '02' });
|
||||
const page2 = await insertTestChapter(testDb, novelId, { page: '2' });
|
||||
const page10 = await insertTestChapter(testDb, novelId, { page: '10' });
|
||||
|
||||
// The cast parameter gives comparisons numeric affinity: '02' and '2'
|
||||
// compare equally, and page 10 sorts after page 2.
|
||||
expect((await getPrevChapter(novelId, 0, '2'))?.id).toBe(page1);
|
||||
expect((await getPrevChapter(novelId, 0, '10'))?.id).toBe(page2);
|
||||
expect((await getNextChapter(novelId, 0, '02'))?.id).toBe(page10);
|
||||
expect((await getPrevChapter(novelId, 0, '02'))?.id).toBe(page1);
|
||||
expect((await getNextChapter(novelId, 0, '1'))?.id).toBe(page02);
|
||||
});
|
||||
|
||||
it('preserves integer-cast behavior for source-defined page labels', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
await insertTestChapter(testDb, novelId, {
|
||||
page: 'Volume A',
|
||||
position: 2,
|
||||
});
|
||||
const chapterId = await insertTestChapter(testDb, novelId, {
|
||||
page: 'Volume B',
|
||||
position: 0,
|
||||
});
|
||||
|
||||
// Labels cast to zero, so existing eligibility treats both as later
|
||||
// pages and sorting resolves their equal page casts by position.
|
||||
expect(await getPrevChapter(novelId, 1, 'Volume A')).toBeUndefined();
|
||||
expect((await getNextChapter(novelId, 1, 'Volume A'))?.id).toBe(
|
||||
chapterId,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips excluded chapters at the previous page boundary', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
const chapterId = await insertTestChapter(testDb, novelId, {
|
||||
page: '1',
|
||||
position: 0,
|
||||
scanlator: null,
|
||||
});
|
||||
await insertTestChapter(testDb, novelId, {
|
||||
page: '1',
|
||||
position: 1,
|
||||
scanlator: 'Excluded',
|
||||
});
|
||||
|
||||
expect((await getPrevChapter(novelId, 0, '2', ['Excluded']))?.id).toBe(
|
||||
chapterId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrevChapter', () => {
|
||||
it('should return previous chapter in same page', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './testData';
|
||||
import {
|
||||
categorySchema,
|
||||
chapterSchema,
|
||||
novelCategorySchema,
|
||||
novelSchema,
|
||||
} from '@database/schema';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
pickCustomNovelCover,
|
||||
updateNovelCategoryById,
|
||||
updateNovelCategories,
|
||||
_restoreNovelAndChapters,
|
||||
} from '../NovelQueries';
|
||||
|
||||
const mockGetLibraryDefaultCategoryId = jest.fn<number | undefined, []>();
|
||||
@@ -252,6 +254,69 @@ describe('NovelQueries', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should assign the categories selected while adding', async () => {
|
||||
const testDb = getTestDb();
|
||||
const firstCategoryId = await insertTestCategory(testDb, {
|
||||
name: 'First selected category',
|
||||
});
|
||||
const secondCategoryId = await insertTestCategory(testDb, {
|
||||
name: 'Second selected category',
|
||||
});
|
||||
const novelId = await insertTestNovel(testDb, {
|
||||
inLibrary: false,
|
||||
path: '/test/selected-categories',
|
||||
pluginId: 'test-plugin',
|
||||
});
|
||||
|
||||
await switchNovelToLibraryQuery(
|
||||
'/test/selected-categories',
|
||||
'test-plugin',
|
||||
[firstCategoryId, secondCategoryId],
|
||||
);
|
||||
|
||||
const associations = await testDb.drizzleDb
|
||||
.select()
|
||||
.from(novelCategorySchema)
|
||||
.where(eq(novelCategorySchema.novelId, novelId))
|
||||
.all();
|
||||
|
||||
expect(associations.map(association => association.categoryId)).toEqual([
|
||||
firstCategoryId,
|
||||
secondCategoryId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use the built-in default when no categories are selected', async () => {
|
||||
const testDb = getTestDb();
|
||||
const previousDefaultCategoryId = await insertTestCategory(testDb, {
|
||||
name: 'Previous default category',
|
||||
});
|
||||
const novelId = await insertTestNovel(testDb, {
|
||||
inLibrary: false,
|
||||
path: '/test/no-selected-categories',
|
||||
pluginId: 'test-plugin',
|
||||
});
|
||||
mockGetLibraryDefaultCategoryId.mockReturnValue(
|
||||
previousDefaultCategoryId,
|
||||
);
|
||||
|
||||
await switchNovelToLibraryQuery(
|
||||
'/test/no-selected-categories',
|
||||
'test-plugin',
|
||||
[],
|
||||
);
|
||||
|
||||
const associations = await testDb.drizzleDb
|
||||
.select()
|
||||
.from(novelCategorySchema)
|
||||
.where(eq(novelCategorySchema.novelId, novelId))
|
||||
.all();
|
||||
|
||||
expect(associations.map(association => association.categoryId)).toEqual([
|
||||
BUILT_IN_CATEGORY_IDS.default,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fall back when the selected category no longer exists', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb, {
|
||||
@@ -395,6 +460,73 @@ describe('NovelQueries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('_restoreNovelAndChapters', () => {
|
||||
it('does not replace an unrelated novel when backup IDs collide', async () => {
|
||||
const testDb = getTestDb();
|
||||
await insertTestNovel(testDb, {
|
||||
path: '/existing/novel',
|
||||
pluginId: 'existing-plugin',
|
||||
name: 'Existing Novel',
|
||||
inLibrary: true,
|
||||
});
|
||||
|
||||
const mapping = await _restoreNovelAndChapters({
|
||||
id: 1,
|
||||
path: '/restored/novel',
|
||||
pluginId: 'restored-plugin',
|
||||
name: 'Restored Novel',
|
||||
cover: null,
|
||||
summary: null,
|
||||
author: null,
|
||||
artist: null,
|
||||
status: 'Ongoing',
|
||||
genres: null,
|
||||
inLibrary: true,
|
||||
isLocal: false,
|
||||
totalPages: 0,
|
||||
chapters: [
|
||||
{
|
||||
id: 10,
|
||||
novelId: 1,
|
||||
path: '/restored/chapter-1',
|
||||
name: 'Chapter 1',
|
||||
releaseTime: null,
|
||||
readTime: null,
|
||||
bookmark: false,
|
||||
unread: true,
|
||||
isDownloaded: true,
|
||||
updatedTime: null,
|
||||
chapterNumber: 1,
|
||||
page: '1',
|
||||
progress: null,
|
||||
position: 0,
|
||||
scanlator: null,
|
||||
timeSpent: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mapping.restoredNovelId).not.toBe(1);
|
||||
expect(
|
||||
(await getNovelByPath('/existing/novel', 'existing-plugin'))?.name,
|
||||
).toBe('Existing Novel');
|
||||
expect(
|
||||
(await getNovelByPath('/restored/novel', 'restored-plugin'))?.id,
|
||||
).toBe(mapping.restoredNovelId);
|
||||
const restoredChapters = await testDb.drizzleDb
|
||||
.select()
|
||||
.from(chapterSchema)
|
||||
.where(eq(chapterSchema.novelId, mapping.restoredNovelId))
|
||||
.all();
|
||||
expect(mapping.chapters).toEqual([
|
||||
{
|
||||
backupChapterId: 10,
|
||||
restoredChapterId: restoredChapters[0].id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNovelInfo', () => {
|
||||
it('should update novel information', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
@@ -8,3 +8,20 @@ INSERT OR IGNORE INTO Category (id, name, sort) VALUES
|
||||
(1, '${escapeSqlString(getString('categories.default'))}', 1),
|
||||
(2, '${escapeSqlString(getString('categories.local'))}', 2)
|
||||
`;
|
||||
|
||||
export const repairDefaultCategoryNameQuery = `
|
||||
UPDATE Category
|
||||
SET name = '${escapeSqlString(getString('categories.default'))}'
|
||||
WHERE id = 1 AND TRIM(name) = ''
|
||||
`;
|
||||
|
||||
// open-lab fork: a fresh install starts with the open-lab plugin repository (its
|
||||
// Kavita plugin reads the self-hosted library). Only when no repository exists yet.
|
||||
export const OPEN_LAB_PLUGIN_REPOSITORY =
|
||||
'https://git.marcospaulo.dev.br/masi/lnreader-plugins/raw/branch/plugins/v3.0.0/.dist/plugins.min.json';
|
||||
|
||||
export const createDefaultRepositoryQuery = `
|
||||
INSERT INTO Repository (url)
|
||||
SELECT '${OPEN_LAB_PLUGIN_REPOSITORY}'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM Repository)
|
||||
`;
|
||||
|
||||
@@ -121,6 +121,16 @@ export interface BackupNovel extends NovelInfo {
|
||||
chapters: ChapterInfo[];
|
||||
}
|
||||
|
||||
export interface RestoredNovelMapping {
|
||||
pluginId: string;
|
||||
backupNovelId: number;
|
||||
restoredNovelId: number;
|
||||
chapters: {
|
||||
backupChapterId: number;
|
||||
restoredChapterId: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface BackupCategory extends Category {
|
||||
novelIds: number[];
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const mockNovel: NovelInfo = {
|
||||
|
||||
const createNovelStore = (
|
||||
overrides: Partial<NovelInfo> = {},
|
||||
switchNovelToLibrary = jest.fn().mockResolvedValue(undefined),
|
||||
switchNovelToLibrary = jest.fn().mockResolvedValue(true),
|
||||
) =>
|
||||
createStore({
|
||||
pluginId: 'plugin-id',
|
||||
@@ -123,7 +123,7 @@ describe('useNovel store', () => {
|
||||
});
|
||||
|
||||
it('toggles follow state after followNovel action', async () => {
|
||||
const switchNovelToLibrary = jest.fn().mockResolvedValue(undefined);
|
||||
const switchNovelToLibrary = jest.fn().mockResolvedValue(true);
|
||||
const store = createNovelStore({ inLibrary: false }, switchNovelToLibrary);
|
||||
|
||||
await store.getState().actions.followNovel();
|
||||
|
||||
@@ -44,6 +44,7 @@ export const defaultLibrarySettings = {
|
||||
displayMode: 'comfortable',
|
||||
showDownloadBadges: true,
|
||||
showUnreadBadges: true,
|
||||
showContinueReadingButton: false,
|
||||
novelsPerRow: 3,
|
||||
sortOrder: 'DateAdded_DESC',
|
||||
};
|
||||
@@ -65,6 +66,8 @@ export const initialChapterGeneralSettings = {
|
||||
bionicReading: false,
|
||||
tapToScroll: false,
|
||||
TTSEnable: true,
|
||||
pageReaderInvertVolumeButtons: false,
|
||||
pageReaderDisableAnimation: false,
|
||||
};
|
||||
|
||||
export const initialChapterReaderSettings = {
|
||||
|
||||
@@ -38,9 +38,14 @@ export default function useDownload() {
|
||||
const downloadingNovelIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
downloadQueue.flatMap(item =>
|
||||
item.task.data.novelId === undefined ? [] : [item.task.data.novelId],
|
||||
),
|
||||
downloadQueue.flatMap(item => [
|
||||
...(item.task.data.novelId === undefined
|
||||
? []
|
||||
: [item.task.data.novelId]),
|
||||
...item.task.data.chapters.flatMap(chapter =>
|
||||
chapter.novelId === undefined ? [] : [chapter.novelId],
|
||||
),
|
||||
]),
|
||||
),
|
||||
[downloadQueue],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { NovelInfo } from '@database/types';
|
||||
import { getString } from '@i18n/translations';
|
||||
import { createNovelStoreActions } from '../store/novelStore.actions';
|
||||
import { createNovelSlice } from '../store/novelStore';
|
||||
|
||||
interface BootstrapServiceSlice {
|
||||
bootstrapNovelAsync: jest.Mock;
|
||||
bootstrapNovelSync: jest.Mock;
|
||||
getNextChapterBatch: jest.Mock;
|
||||
loadUpToBatch: jest.Mock;
|
||||
}
|
||||
|
||||
const mockNovel: NovelInfo = {
|
||||
id: 1,
|
||||
path: '/novels/x',
|
||||
name: 'X',
|
||||
pluginId: 'plugin.test',
|
||||
};
|
||||
|
||||
interface TestState {
|
||||
novel: NovelInfo | undefined;
|
||||
novelPath: string;
|
||||
pluginId: string;
|
||||
pageIndex: number;
|
||||
pages: string[];
|
||||
novelSettings: { filter: never[]; excludedScanlators: never[] };
|
||||
loading: boolean;
|
||||
fetching: boolean;
|
||||
error: string | undefined;
|
||||
}
|
||||
|
||||
const createHarness = (overrides: Partial<TestState> = {}) => {
|
||||
let state: TestState = {
|
||||
novel: undefined,
|
||||
novelPath: '/x',
|
||||
pluginId: 'plugin.test',
|
||||
pageIndex: 0,
|
||||
pages: [],
|
||||
novelSettings: { filter: [], excludedScanlators: [] },
|
||||
loading: false,
|
||||
fetching: false,
|
||||
error: undefined,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const set = jest.fn(
|
||||
(partial: Partial<TestState> | ((s: TestState) => Partial<TestState>)) => {
|
||||
const patch = typeof partial === 'function' ? partial(state) : partial;
|
||||
state = { ...state, ...patch };
|
||||
},
|
||||
);
|
||||
const get = () => state;
|
||||
const bootstrapService: jest.Mocked<BootstrapServiceSlice> = {
|
||||
bootstrapNovelAsync: jest.fn(),
|
||||
bootstrapNovelSync: jest.fn(),
|
||||
getNextChapterBatch: jest.fn(),
|
||||
loadUpToBatch: jest.fn(),
|
||||
};
|
||||
let requestVersion = 0;
|
||||
const chapterRequestCoordinator = {
|
||||
current: jest.fn(() => requestVersion),
|
||||
invalidate: jest.fn(() => ++requestVersion),
|
||||
};
|
||||
const transformChapters = jest.fn(chs => chs);
|
||||
|
||||
const actions = createNovelStoreActions({
|
||||
//@ts-expect-error partial state/actions for testing
|
||||
set, //@ts-expect-error
|
||||
get,
|
||||
deps: {
|
||||
bootstrapService,
|
||||
chapterActionsDependencies: {} as never,
|
||||
chapterRequestCoordinator,
|
||||
transformChapters,
|
||||
} as never,
|
||||
defaultChapterSort: 'positionAsc',
|
||||
});
|
||||
|
||||
return {
|
||||
actions,
|
||||
getState: () => state,
|
||||
bootstrapService,
|
||||
chapterRequestCoordinator,
|
||||
};
|
||||
};
|
||||
|
||||
describe('novelStore.bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('starts in a loading state until the initial bootstrap completes', () => {
|
||||
const state = createNovelSlice({
|
||||
pluginId: 'plugin.test',
|
||||
novelPath: '/x',
|
||||
initialNovelSettings: {
|
||||
sort: 'positionAsc',
|
||||
filter: [],
|
||||
showChapterTitles: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.loading).toBe(true);
|
||||
expect(state.fetching).toBe(true);
|
||||
});
|
||||
|
||||
it('maps a missing novel to the not-found error', async () => {
|
||||
const harness = createHarness();
|
||||
harness.bootstrapService.bootstrapNovelAsync.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'missing-novel',
|
||||
});
|
||||
|
||||
const ok = await harness.actions.bootstrapNovel();
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(harness.getState().error).toBe(getString('novelScreen.notFound'));
|
||||
expect(harness.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('maps a bootstrap error to the load-failed message, never the raw error', async () => {
|
||||
const harness = createHarness();
|
||||
harness.bootstrapService.bootstrapNovelAsync.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'error',
|
||||
error: new Error('boom'),
|
||||
});
|
||||
|
||||
const ok = await harness.actions.bootstrapNovel();
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(harness.getState().error).toBe(getString('novelScreen.loadFailed'));
|
||||
expect(harness.getState().error).not.toBe('boom');
|
||||
});
|
||||
|
||||
it('maps a missing-chapters failure to the unable-to-get-novel message', async () => {
|
||||
const harness = createHarness();
|
||||
harness.bootstrapService.bootstrapNovelAsync.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'missing-chapters',
|
||||
});
|
||||
|
||||
const ok = await harness.actions.bootstrapNovel();
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(harness.getState().error).toBe(
|
||||
getString('updatesScreen.unableToGetNovel'),
|
||||
);
|
||||
expect(harness.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the error on a successful bootstrap', async () => {
|
||||
const harness = createHarness({ error: 'previous error' });
|
||||
harness.bootstrapService.bootstrapNovelAsync.mockResolvedValue({
|
||||
ok: true,
|
||||
novel: mockNovel,
|
||||
pages: ['1'],
|
||||
pageIndex: 0,
|
||||
scanlators: [],
|
||||
chapters: [],
|
||||
batchInformation: { batch: 0, total: 0, totalChapters: 0 },
|
||||
firstUnreadChapter: undefined,
|
||||
});
|
||||
|
||||
const ok = await harness.actions.bootstrapNovel();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(harness.getState().error).toBeUndefined();
|
||||
expect(harness.getState().novel).toBe(mockNovel);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,10 @@ interface Props {
|
||||
path: string;
|
||||
novel?: NovelInfo;
|
||||
defaultChapterSort: ChapterOrderKey;
|
||||
switchNovelToLibrary: (novelPath: string, pluginId: string) => Promise<void>;
|
||||
switchNovelToLibrary: (
|
||||
novelPath: string,
|
||||
pluginId: string,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createStore({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
|
||||
import { getString } from '@i18n/translations';
|
||||
import { NovelSettings } from '../types';
|
||||
import { BootstrapFailureResult } from '../store-helper/bootstrapService';
|
||||
import {
|
||||
GetState,
|
||||
NovelStoreDependencies,
|
||||
@@ -14,6 +16,17 @@ interface CreateNovelStoreActionsParams {
|
||||
defaultChapterSort: ChapterOrderKey;
|
||||
}
|
||||
|
||||
const resolveBootstrapError = (result: BootstrapFailureResult): string => {
|
||||
if (result.reason === 'missing-chapters') {
|
||||
return getString('updatesScreen.unableToGetNovel');
|
||||
}
|
||||
if (result.reason === 'error') {
|
||||
// A real fetch/parse failure — don't claim the novel is missing.
|
||||
return getString('novelScreen.loadFailed');
|
||||
}
|
||||
return getString('novelScreen.notFound');
|
||||
};
|
||||
|
||||
export const createNovelStoreActions = ({
|
||||
set,
|
||||
get,
|
||||
@@ -42,7 +55,7 @@ export const createNovelStoreActions = ({
|
||||
|
||||
const requestId = deps.chapterRequestCoordinator.invalidate();
|
||||
inflightBootstrap = (async () => {
|
||||
set({ loading: true, fetching: true });
|
||||
set({ loading: true, fetching: true, error: undefined });
|
||||
|
||||
const state = get();
|
||||
const result = await deps.bootstrapService.bootstrapNovelAsync({
|
||||
@@ -63,6 +76,7 @@ export const createNovelStoreActions = ({
|
||||
set({
|
||||
loading: false,
|
||||
fetching: false,
|
||||
error: resolveBootstrapError(result),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -248,7 +262,11 @@ export const createNovelStoreActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.switchNovelToLibrary(state.novelPath, state.pluginId);
|
||||
const completed = await deps.switchNovelToLibrary(
|
||||
state.novelPath,
|
||||
state.pluginId,
|
||||
);
|
||||
if (!completed) return;
|
||||
set(inner => {
|
||||
if (!inner.novel) {
|
||||
return {};
|
||||
|
||||
@@ -21,8 +21,9 @@ export const createNovelSlice = ({
|
||||
initialLastRead,
|
||||
}: CreateNovelStoreParams) => {
|
||||
return {
|
||||
loading: false,
|
||||
fetching: false,
|
||||
loading: true,
|
||||
fetching: true,
|
||||
error: undefined,
|
||||
pluginId,
|
||||
novelPath,
|
||||
novel,
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ChapterSliceState {
|
||||
export interface NovelStoreData extends ChapterSliceState {
|
||||
loading: boolean;
|
||||
fetching: boolean;
|
||||
error: string | undefined;
|
||||
|
||||
pluginId: string;
|
||||
novelPath: string;
|
||||
@@ -95,7 +96,10 @@ export interface NovelStoreDependencies {
|
||||
persistPageIndex?: (value: number) => void;
|
||||
persistNovelSettings?: (value: NovelSettings) => void;
|
||||
persistLastRead?: (value: ChapterInfo) => void;
|
||||
switchNovelToLibrary?: (novelPath: string, pluginId: string) => Promise<void>;
|
||||
switchNovelToLibrary?: (
|
||||
novelPath: string,
|
||||
pluginId: string,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type SetState = {
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface BrowseSettings {
|
||||
export interface LibrarySettings {
|
||||
/** User-selected category for newly added novels. */
|
||||
defaultCategoryId?: number;
|
||||
/** Prompt for categories whenever a novel is added to the library. */
|
||||
promptForCategoryOnAdd?: boolean;
|
||||
/** Last category viewed in the library. */
|
||||
lastUsedCategoryId?: number;
|
||||
globalUpdateExcludeCategoryIds?: number[];
|
||||
@@ -122,6 +124,7 @@ export interface LibrarySettings {
|
||||
filter?: LibraryFilter;
|
||||
showDownloadBadges?: boolean;
|
||||
showUnreadBadges?: boolean;
|
||||
showContinueReadingButton?: boolean;
|
||||
showNumberOfNovels?: boolean;
|
||||
displayMode?: DisplayModes;
|
||||
novelsPerRow?: number;
|
||||
@@ -191,6 +194,8 @@ export interface ChapterGeneralSettings {
|
||||
bionicReading: boolean;
|
||||
tapToScroll: boolean;
|
||||
TTSEnable: boolean;
|
||||
pageReaderInvertVolumeButtons: boolean;
|
||||
pageReaderDisableAnimation: boolean;
|
||||
}
|
||||
|
||||
export interface ReaderTheme {
|
||||
@@ -316,6 +321,8 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
|
||||
bionicReading: false,
|
||||
tapToScroll: false,
|
||||
TTSEnable: true,
|
||||
pageReaderInvertVolumeButtons: false,
|
||||
pageReaderDisableAnimation: false,
|
||||
};
|
||||
|
||||
export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
@@ -376,12 +383,14 @@ export const useBrowseSettings = () => {
|
||||
};
|
||||
|
||||
const defaultLibrarySettings: LibrarySettings = {
|
||||
promptForCategoryOnAdd: false,
|
||||
showNumberOfNovels: false,
|
||||
downloadedOnlyMode: false,
|
||||
incognitoMode: false,
|
||||
displayMode: DisplayModes.Comfortable,
|
||||
showDownloadBadges: true,
|
||||
showUnreadBadges: true,
|
||||
showContinueReadingButton: false,
|
||||
novelsPerRow: 3,
|
||||
sortOrder: LibrarySortOrder.DateAdded_DESC,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,14 +242,14 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
"plugins": "Plugins",
|
||||
"removeFromLibrary": "Removed from library",
|
||||
"repositories": "Repositories",
|
||||
"searchbar": "Search sources and plugins",
|
||||
"searchbar": "Search sources",
|
||||
"searchResults": "Search results",
|
||||
"selectNovel": "Select Novel",
|
||||
"sources": "Sources",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "History",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "There's no next chapter",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"aboutScreen": {
|
||||
"website": "الموقع",
|
||||
"discord": "ديس كورد",
|
||||
"github": "كيت هاب",
|
||||
"github": "Arabic",
|
||||
"helpTranslate": "المساعدة في الترجَمة",
|
||||
"plugins": "Plugins",
|
||||
"version": "آلإصدار",
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "إدارة البيانات",
|
||||
"deleteReadChapters": "حذف الفصول المقروءة",
|
||||
"deleteReadChaptersDialogTitle": "حذف جميع الفصول التي تم قراءتها",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "تمت إعادة إنشاء فهارس قاعدة البيانات",
|
||||
"recreateDBIndexesDialogTitle": "سيتم إعادة إنشاء جميع فهارس قاعدة البيانات.\nقد يستغرق هذا بعض الوقت.",
|
||||
"recreateDBIndexesDesc": "يعيد إنشاء فهارس قاعدة البيانات. قد يؤدي هذا إلى تحسين الأداء على الأجهزة الأبطأ.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "مظهر",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "تحضير البيانات",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "لا يمكنك حذف التصنيف الافتراضي",
|
||||
"default": "افتراضي",
|
||||
"defaultCategory": "التصنيف الافتراضي",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "هل ترغب في حذف التصنيف",
|
||||
"header": "حذف التصنيف"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "ليس لديك تصنيفات. اضغط على زر تعديل لإنشاء واحد لتنظيم مكتبتك"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "ليس لديك مستودعات. أضف مستودع الإضافات الأول لتبدأ."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "ليس لديك مستودعات. أضف مستودع الإضافات الأول لتبدأ.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "حول",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "إرسال",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "تحذير"
|
||||
"warning": "تحذير",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "تحديث",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "لم يتم العثور على الإضافة!",
|
||||
"removeDownloadsWarning": "هل أنت متأكد؟ سيتم حذف جميع الفصول المحملة."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "العامة",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "جميع المصادر",
|
||||
"searchIn": "البحث عن رواية في"
|
||||
"searchIn": "البحث عن رواية في",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "السجل",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "نقل",
|
||||
"noSummary": "لا يوجد ملخص",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "التمرير إلى الفصل الحالي",
|
||||
"scrollToTop": "التمرير إلى الأعلى"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>تعذر تحميل الفصل</h2><p>لم يتم العثور على محتوى قابل للقراءة. تحقق من الفصل في WebView؛ إذا تم تحميله هناك، <a href='%{reportUrl}'>فأبلغ عن مشكلة الإضافة</a>.</p><p>الإضافة: %{pluginId}<br>الرواية: %{novelName}<br>الفصل: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "انتهى",
|
||||
"nextChapter": "التالي: %{name}",
|
||||
"noNextChapter": "لا يوجد فصل تالي",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "ডিফল্ট শ্ৰেণী",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "আপুনি শ্ৰেণী মচিব বিচাৰে নেকি?",
|
||||
"header": "শ্ৰেণী মচক"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "আপোনাৰ কোনো শ্ৰেণী নাই। সম্পাদনা বুটামত টেপ কৰি এটি সৃষ্টি কৰক"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "জমা দিয়ক",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "সকলো উৎস",
|
||||
"searchIn": "উৎসত উপন্যাস অনুসন্ধান কৰক"
|
||||
"searchIn": "উৎসত উপন্যাস অনুসন্ধান কৰক",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "ইতিহাস",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "স্থানান্তৰিত কৰক",
|
||||
"noSummary": "কোনো সংক্ষিপ্তসাৰ নাই",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "বৰ্তমান অধ্যায়লৈ স্ক্ৰল কৰক",
|
||||
"scrollToTop": "শীৰ্ষলৈ স্ক্ৰল কৰক"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "শেষ",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "পৰবর্তী অধ্যায় নাই",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,14 +242,14 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
"plugins": "Plugins",
|
||||
"removeFromLibrary": "Removed from library",
|
||||
"repositories": "Repositories",
|
||||
"searchbar": "Search sources and plugins",
|
||||
"searchbar": "Search sources",
|
||||
"searchResults": "Search results",
|
||||
"selectNovel": "Select Novel",
|
||||
"sources": "Sources",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "History",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "There's no next chapter",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Výchozí kategorie",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Přejete si odstranit kategorii",
|
||||
"header": "Smazat kategorii"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "Nemáte žádné kategorie. Klepněte na tlačítko editovat pro vytvoření nové kategorie"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Odeslat",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "všechny zdroje",
|
||||
"searchIn": "Hledat novelu v"
|
||||
"searchIn": "Hledat novelu v",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "Historie",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrovat",
|
||||
"noSummary": "Žádné shrnutí",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Přejít na aktuální kapitolu",
|
||||
"scrollToTop": "Přejít na začátek"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Dokončeno",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "Nejsou žádné další kapitoly",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,14 +242,14 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
"plugins": "Plugins",
|
||||
"removeFromLibrary": "Removed from library",
|
||||
"repositories": "Repositories",
|
||||
"searchbar": "Search sources and plugins",
|
||||
"searchbar": "Search sources",
|
||||
"searchResults": "Search results",
|
||||
"selectNovel": "Select Novel",
|
||||
"sources": "Sources",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "History",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "There's no next chapter",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Gelesene Kapitel löschen",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Standard-Kategorie",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Möchten Sie diese Kategorie löschen?",
|
||||
"header": "Kategorie löschen"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "Sie haben keine Kategorien. Tippen Sie auf die Plus-Schaltfläche, um eine für die Organisation Ihrer Bibliothek zu erstellen"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Bestätigen",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "Allgemein",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "Alle Quellen",
|
||||
"searchIn": "Suche ein Buch in"
|
||||
"searchIn": "Suche ein Buch in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "Verlauf",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrieren",
|
||||
"noSummary": "Keine Beschreibung",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Zum aktuellen Kapitel springen",
|
||||
"scrollToTop": "Zum Anfang springen"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Beendet",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "Es gibt kein weiteres Kapitel",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -51,7 +55,7 @@
|
||||
"appLanguageDefault": "Default",
|
||||
"appTheme": "App theme",
|
||||
"darkTheme": "Dark Theme",
|
||||
"dateFormat": "Date format",
|
||||
"dateFormat": "Μορφοποίησή ημερομηνίας",
|
||||
"dateFormatDefault": "Default (%{date})",
|
||||
"dynamicColors": "Dynamic",
|
||||
"hideBackdrop": "Hide backdrop",
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Υποβολή",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "Ιστορικό",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Κύλιση στο τρέχον κεφάλαιο",
|
||||
"scrollToTop": "Κύλιση προς τα πάνω"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "Δεν υπάρχει επόμενο κεφάλαιο",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -383,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -429,6 +443,7 @@
|
||||
"jumpToLastReadChapter": "Jump to last read chapter in list",
|
||||
"novel": "Novel",
|
||||
"novelBadges": "Novel Badges",
|
||||
"showContinueReadingButton": "Show continue reading button",
|
||||
"novelSort": "Novel Sort",
|
||||
"refreshMetadata": "Automatically refresh metadata",
|
||||
"refreshMetadataDescription": "Check for new cover and details when updating library",
|
||||
@@ -440,7 +455,8 @@
|
||||
"updateLibrary": "Update library on launch",
|
||||
"updateLibraryDesc": "Not recommended for low-end devices",
|
||||
"updateTime": "Show last update time",
|
||||
"useFAB": "Use FAB in Library",
|
||||
"useFAB": "Show library resume button",
|
||||
"useFABDescription": "Show a floating button that opens the most recently read chapter.",
|
||||
"timeTracking": "Time Tracking",
|
||||
"enableTimeTracking": "Enable time tracking",
|
||||
"enableTimeTrackingDesc": "Tracks time spent reading and displays it in the statistics screen",
|
||||
@@ -450,7 +466,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "History",
|
||||
"historyScreen": {
|
||||
@@ -469,12 +488,14 @@
|
||||
"badges": "Badges",
|
||||
"comfortable": "Comfortable grid",
|
||||
"compact": "Compact grid",
|
||||
"continueReadingButton": "Continue reading button",
|
||||
"displayMode": "Display mode",
|
||||
"download": "Download",
|
||||
"downloadBadges": "Download badges",
|
||||
"list": "List",
|
||||
"noTitle": "Cover only grid",
|
||||
"numberOfItems": "Number of Items",
|
||||
"overlays": "Overlays",
|
||||
"showNoOfItems": "Show number of items",
|
||||
"unread": "Unread",
|
||||
"unreadBadges": "Unread badges"
|
||||
@@ -600,9 +621,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -645,6 +668,10 @@
|
||||
"padding": "Padding",
|
||||
"pageReader": "Paged reading (Experimental)",
|
||||
"pageReaderDescription": "Split the chapter into pages instead of scrolling. Experimental — may break formatting in some novels",
|
||||
"invertVolumeButtons": "Invert volume buttons",
|
||||
"invertVolumeButtonsDescription": "Swap volume up and down buttons for previous and next page",
|
||||
"disablePageTransitions": "Disable page transition animation",
|
||||
"disablePageTransitionsDescription": "Turn pages instantly without slide animation",
|
||||
"removeExtraSpacing": "Remove extra spacing",
|
||||
"removeExtraSpacingDescription": "Collapse extra blank lines between paragraphs",
|
||||
"scrollAmount": "Scroll amount (screen height by default)",
|
||||
@@ -713,9 +740,14 @@
|
||||
"genreDistribution": "Genre distribution",
|
||||
"novels": "novels",
|
||||
"readChapters": "Read chapters",
|
||||
"readLabel": "Read",
|
||||
"downloadedLabel": "Downloaded",
|
||||
"sources": "Sources",
|
||||
"statusDistribution": "Status distribution",
|
||||
"title": "Statistics",
|
||||
"readingTime": "Reading time",
|
||||
"chaptersInLibrary": "chapters in library",
|
||||
"customizeGenres": "Customize",
|
||||
"titlesInLibrary": "Titles in library",
|
||||
"totalChapters": "Total chapters",
|
||||
"unreadChapters": "Unread chapters",
|
||||
@@ -789,7 +821,20 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"customCodeSettings": {
|
||||
"description": "Rules and snippets run on every chapter you read, in the order shown.",
|
||||
"textRules": "Text Rules",
|
||||
"ruleCount": {
|
||||
"one": "%{count} rule",
|
||||
"other": "%{count} rules"
|
||||
},
|
||||
"activeSnippetCount": {
|
||||
"one": "%{count} of %{total} active",
|
||||
"other": "%{count} of %{total} active"
|
||||
},
|
||||
"addReplaceRule": "Add replacement",
|
||||
"addRemoveRule": "Add removal",
|
||||
"renameHint": "Long press to rename",
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
@@ -804,11 +849,16 @@
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"regexHint": "Plain text affects all matches. For regex, use /pattern/flags and add g to affect all matches.",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"beautifyCode": "Beautify code",
|
||||
"beautifyFailed": "Couldn't format the code. Check its syntax and try again.",
|
||||
"syntaxHighlighting": "Syntax highlighting",
|
||||
"importCode": "Import code",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
@@ -828,12 +878,21 @@
|
||||
"one": "%{count} subgenre",
|
||||
"other": "%{count} subgenres"
|
||||
},
|
||||
"novelsIn": "Novels in %{genre}",
|
||||
"novels": "Novels",
|
||||
"noNovels": "No novels found for this category",
|
||||
"seeAllNovels": "See all",
|
||||
"editTaxonomy": "Edit genre taxonomy",
|
||||
"taxonomyTitle": "Genre taxonomy",
|
||||
"parentCategories": "Parent categories",
|
||||
"parentCategories": "Genre groups",
|
||||
"genreGroups": "Genre groups",
|
||||
"taxonomyDescription": "Statistics group each novel's genres under these groups. Genres outside a group are counted individually.",
|
||||
"newGroup": "New group",
|
||||
"editGenreGroup": "Edit genre group",
|
||||
"genresInGroup": "Genres in this group",
|
||||
"noGenresInGroup": "No genres yet",
|
||||
"foundInLibrary": "Found in your library",
|
||||
"ungroupedGenresDescription": "Genres your sources use that aren't in any group yet",
|
||||
"deleteGroupConfirm": "Delete this genre group? Its genres will be counted individually.",
|
||||
"normalization": "Normalization rules",
|
||||
"addCategory": "Add category",
|
||||
"addChild": "Add subgenre",
|
||||
@@ -842,11 +901,11 @@
|
||||
"deleteCategoryConfirm": "Delete this category and its subgenres?",
|
||||
"deleteChildConfirm": "Delete this subgenre?",
|
||||
"deleteNormalizationConfirm": "Delete this normalization rule?",
|
||||
"parentNamePlaceholder": "Category name",
|
||||
"childNamePlaceholder": "Add Subgenre",
|
||||
"parentNamePlaceholder": "Group name",
|
||||
"childNamePlaceholder": "Add genre",
|
||||
"normalizationKeyPlaceholder": "e.g. sci-fi",
|
||||
"normalizationValuePlaceholder": "e.g. Sci-Fi",
|
||||
"noCategories": "No parent categories configured",
|
||||
"noCategories": "No genre groups yet",
|
||||
"noNormalization": "No normalization rules configured"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Administración de datos",
|
||||
"deleteReadChapters": "Eliminar capítulos leídos",
|
||||
"deleteReadChaptersDialogTitle": "Todos los capítulos marcados como leídos serán borrados.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Índices de la Base de Datos Recreados",
|
||||
"recreateDBIndexesDialogTitle": "Todos los índices de la base de datos serán Recreados. Esto puede tomar un tiempo.",
|
||||
"recreateDBIndexesDesc": "Recrea los índices de la Base de Datos. Esto puede mejorar el rendimiento en dispositivos más lentos.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Apariencia",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparando datos",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "No se puede eliminar la categoría predeterminada",
|
||||
"default": "Predeterminado",
|
||||
"defaultCategory": "Categoría por defecto",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "¿Deseas eliminar la categoría?",
|
||||
"header": "Eliminar categoría"
|
||||
@@ -287,13 +292,17 @@
|
||||
"setModalEmptyMsg": "No tienes categorías. Pulsa el botón Editar para crear una para organizar tu biblioteca"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "No tienes repositorios. Añade tu primer repositorio de plugins para empezar."
|
||||
"disable": "Deshabilitar",
|
||||
"disableTitle": "¿Desactivar repositorio?",
|
||||
"disableWarning": "LNReader dejará de buscar actualizaciones en %{name}. Sus plugins se ocultarán a menos que otro repositorio activo los proporcione. Los plugins instalados permanecerán instalados.",
|
||||
"emptyMsg": "No tienes repositorios. Añade tu primer repositorio de plugins para empezar.",
|
||||
"toggle": "Alternar repositorio %{name}"
|
||||
},
|
||||
"common": {
|
||||
"about": "Acerca de",
|
||||
"add": "Añadir",
|
||||
"all": "Todos",
|
||||
"back": "Back",
|
||||
"back": "Volver",
|
||||
"backup": "Copia de seguridad",
|
||||
"cancel": "Cancelar",
|
||||
"categories": "Categorías",
|
||||
@@ -311,14 +320,14 @@
|
||||
"filter": "Filtrar",
|
||||
"globally": "globalmente",
|
||||
"install": "Instalar",
|
||||
"imported": "Imported",
|
||||
"later": "Later",
|
||||
"imported": "Importado",
|
||||
"later": "Más tarde",
|
||||
"logout": "Cerrar sesión",
|
||||
"name": "Nombre",
|
||||
"newUpdateAvailable": "Nueva actualización disponible",
|
||||
"none": "None",
|
||||
"none": "Ninguno",
|
||||
"ok": "Ok",
|
||||
"close": "Close",
|
||||
"close": "Cerrar",
|
||||
"pause": "Pausa",
|
||||
"preparing": "Preparando",
|
||||
"remove": "Eliminar",
|
||||
@@ -327,75 +336,89 @@
|
||||
"resume": "Reanudar",
|
||||
"retry": "Reintentar",
|
||||
"save": "Guardar",
|
||||
"saved": "Saved",
|
||||
"saved": "Guardar",
|
||||
"search": "Buscar",
|
||||
"skipVersion": "Skip version",
|
||||
"skipVersion": "Saltar versión",
|
||||
"searchFor": "Buscar por",
|
||||
"searchResults": "Resultados de la búsqueda",
|
||||
"settings": "Configuración",
|
||||
"show": "Mostrar",
|
||||
"signIn": "Sign in",
|
||||
"signOut": "Sign out",
|
||||
"signIn": "Entrar",
|
||||
"signOut": "Salir",
|
||||
"sort": "Ordenar",
|
||||
"submit": "Enviar",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"loading": "Cargando ...",
|
||||
"moreOptions": "Más opciones",
|
||||
"warning": "Aviso",
|
||||
"code": "Código",
|
||||
"custom_code": "Cód. Propio",
|
||||
"textToReplace": "Texto a buscar",
|
||||
"replaceWith": "Remplazar con",
|
||||
"replaceText": "Remplazar texto"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
"share": "Share",
|
||||
"openInWebView": "Open in WebView",
|
||||
"openInBrowser": "Open in browser",
|
||||
"clearCookies": "Clear cookies",
|
||||
"cookiesCleared": "Cookies cleared",
|
||||
"clearData": "Clear WebView data",
|
||||
"dataDeleted": "WebView data cleared"
|
||||
"refresh": "Recargar",
|
||||
"share": "Compartir",
|
||||
"openInWebView": "Abrir en vista web",
|
||||
"openInBrowser": "Abrir en el navegador",
|
||||
"clearCookies": "Eliminar cookies",
|
||||
"cookiesCleared": "Cookies borradas",
|
||||
"clearData": "Borrar datos de vista web",
|
||||
"dataDeleted": "Datos de vista web borrados"
|
||||
},
|
||||
"date": {
|
||||
"calendar": {
|
||||
"lastDay": "[Yesterday]",
|
||||
"lastWeek": "[Last] dddd",
|
||||
"nextDay": "[Tomorrow]",
|
||||
"sameDay": "[Today]"
|
||||
"lastDay": "[Ayer]",
|
||||
"lastWeek": "[El] dddd [pasado]",
|
||||
"nextDay": "[Mañana]",
|
||||
"sameDay": "[Hoy]"
|
||||
}
|
||||
},
|
||||
"downloadScreen": {
|
||||
"cancelDownloads": "Cancel downloads",
|
||||
"cancelled": "Downloads cancelled.",
|
||||
"chapterEmptyOrScrapeError": "Either chapter is empty or the app couldn't scrape it",
|
||||
"chapterName": "Chapter: %{name}",
|
||||
"completed": "Download completed",
|
||||
"storageInfo": "Downloaded chapters are stored in the app's private storage on this device.",
|
||||
"downloading": "Downloading",
|
||||
"downloadingNovel": "Downloading: %{name}",
|
||||
"cancelDownloads": "Cancelar descargas",
|
||||
"cancelled": "Descarga cancelada.",
|
||||
"chapterEmptyOrScrapeError": "El capítulo está vacío o la app no pudo extraer el contenido",
|
||||
"chapterName": "Capítulo: %{name}",
|
||||
"completed": "Descarta completada",
|
||||
"storageInfo": "Los capítulos descargados se guardan en el almacenamiento privado de la app en este dispositivo.",
|
||||
"downloading": "Descargando",
|
||||
"downloadingNovel": "Descargando:%{name}",
|
||||
"downloadsLower": "descargas",
|
||||
"noDownloads": "No hay descargas",
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
"pluginNotFound": "¡Plugin no funciona!",
|
||||
"removeDownloadsWarning": "¿Estás seguro? Se eliminarán todos los capítulos descargados."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancelar tarea",
|
||||
"cancelTaskTitle": "¿Cancelar tarea?",
|
||||
"cancelTaskConfirmation": "¿Estás seguro de que quieres cancelar %{task}?",
|
||||
"keepTaskAction": "Conservar tarea"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"restart": "Restart the application"
|
||||
"title": "Ocurrió un error inesperado",
|
||||
"description": "La aplicación experimentó un error inesperado. Por favor, comparte los registros de fallo en nuestro canal de soporte de Discord.",
|
||||
"copyStackTrace": "Copiar seguimiento del error",
|
||||
"shareCrashLogs": "Compartir registro de fallos",
|
||||
"shareCrashLogsFailed": "No se pudieron compartir los registros de fallo. En su lugar, se copió el seguimiento del error.",
|
||||
"shareCrashLogsFailedWithoutCopy": "No se pudieron compartir los registros de fallo ni copiar el seguimiento del error.",
|
||||
"restart": "Reiniciar aplicación"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
"generalSettingsScreen": {
|
||||
"asc": "(Ascendente)",
|
||||
"autoDownload": "Descarga automática",
|
||||
"automaticUpdates": "Automatic updates",
|
||||
"automaticUpdatesDaily": "Daily",
|
||||
"automaticUpdatesDescription": "Runs when a network connection is available. Android may delay background work to save battery.",
|
||||
"automaticUpdatesEvery12Hours": "Every 12 hours",
|
||||
"automaticUpdatesEvery2Days": "Every 2 days",
|
||||
"automaticUpdatesEvery3Days": "Every 3 days",
|
||||
"automaticUpdatesOff": "Off",
|
||||
"automaticUpdatesWeekly": "Weekly",
|
||||
"automaticUpdates": "Actualizaciones automáticas",
|
||||
"automaticUpdatesDaily": "Diariamente",
|
||||
"automaticUpdatesDescription": "Ejecuta cuando hay disponible una conexión de red. Android puede retrasar el trabajo en segundo plano para ahorrar batería.",
|
||||
"automaticUpdatesEvery12Hours": "Cada 12 horas",
|
||||
"automaticUpdatesEvery2Days": "Cada 2 días",
|
||||
"automaticUpdatesEvery3Days": "Cada 3 días",
|
||||
"automaticUpdatesOff": "Desactivar",
|
||||
"automaticUpdatesWeekly": "Semanal",
|
||||
"bySource": "Por fuente",
|
||||
"chapterSort": "Orden de capítulo por defecto",
|
||||
"desc": "(Descendente)",
|
||||
"disableLoadingAnimations": "Disable loading animations",
|
||||
"disableLoadingAnimations": "Desactivar animación de carga",
|
||||
"disableLoadingAnimationsDesc": "May improve performance on slower devices",
|
||||
"disableHapticFeedback": "Deshabilitar la respuesta háptica",
|
||||
"disableHapticFeedbackDescription": "Turn off vibrations for touch interactions.",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "Todas las fuentes",
|
||||
"searchIn": "Buscar una novela en"
|
||||
"searchIn": "Buscar una novela en",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "Historial",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrar",
|
||||
"noSummary": "Sin resumen",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Desplazar al capítulo actual",
|
||||
"scrollToTop": "Desplazar a arriba"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finalizado",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "No hay próximo capítulo",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,14 +242,14 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
"plugins": "Plugins",
|
||||
"removeFromLibrary": "Removed from library",
|
||||
"repositories": "Repositories",
|
||||
"searchbar": "Search sources and plugins",
|
||||
"searchbar": "Search sources",
|
||||
"searchResults": "Search results",
|
||||
"selectNovel": "Select Novel",
|
||||
"sources": "Sources",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "History",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "There's no next chapter",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"advancedSettings": "Avancé",
|
||||
"advancedSettingsScreen": {
|
||||
"cachedNovelsDeletedToast": "Romans en cache supprimés",
|
||||
"chapterInsertFailed": "Échec de l'insertion du chapitre",
|
||||
"chapterInsertFailed": "Insertion de chapitre échoué",
|
||||
"clearCachedNovels": "Vider les romans en cache",
|
||||
"clearCachedNovelsDesc": "Supprimer les romans en cache qui ne sont pas dans votre bibliothèque",
|
||||
"clearDatabaseWarning": "Les progrès et les chapitres lus et téléchargés hors de la bibliothèque, seront perdus.",
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Gestion de données",
|
||||
"deleteReadChapters": "Supprimer les chapitres lus",
|
||||
"deleteReadChaptersDialogTitle": "Tous les chapitres marqués comme lus seront supprimés.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Index de la base de données recréés",
|
||||
"recreateDBIndexesDialogTitle": "Tous les index de la base de données seront recréés.\nCela peut prendre un certain temps.",
|
||||
"recreateDBIndexesDesc": "Recrée les index de la base de données. Cela peut améliorer les performances sur les appareils plus lents.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Apparence",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "Vous ne pouvez pas supprimer la catégorie par défaut",
|
||||
"default": "Défaut",
|
||||
"defaultCategory": "Catégorie par défaut",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Voulez-vous supprimer cette catégorie",
|
||||
"header": "Supprimer la catégorie"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "Vous n'avez pas de catégories. Appuyez sur le bouton Modifier pour en créer une pour organiser votre bibliothèque"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "À propos",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Soumettre",
|
||||
"loading": "Chargement ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Actualiser",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin introuvable!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "Général",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "toutes les sources",
|
||||
"searchIn": "Rechercher un novel dans"
|
||||
"searchIn": "Rechercher un novel dans",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "Historique",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrer",
|
||||
"noSummary": "Aucun résumé",
|
||||
"noCoverFound": "Aucune couverture trouvée",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Commencer à lire %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Défiler vers le chapitre actuel",
|
||||
"scrollToTop": "Défiler jusqu'en haut"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Impossible de charger le chapitre</h2><p>Aucun contenu lisible n'a été trouvé. Vérifiez le chapitre dans la WebView ; s'il s'y charge, <a href='%{reportUrl}'>signalez le problème du plugin</a>.</p><p>Plugin : %{pluginId}<br>Roman : %{novelName}<br>Chapitre : %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Terminé",
|
||||
"nextChapter": "Suivant: %{name}",
|
||||
"noNextChapter": "Il n'y a pas de prochain chapitre",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "ניהול מידע",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "תצוגה",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,14 +242,14 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
"plugins": "Plugins",
|
||||
"removeFromLibrary": "Removed from library",
|
||||
"repositories": "Repositories",
|
||||
"searchbar": "Search sources and plugins",
|
||||
"searchbar": "Search sources",
|
||||
"searchResults": "Search results",
|
||||
"selectNovel": "Select Novel",
|
||||
"sources": "Sources",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "History",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "There's no next chapter",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,14 +242,14 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
"plugins": "Plugins",
|
||||
"removeFromLibrary": "Removed from library",
|
||||
"repositories": "Repositories",
|
||||
"searchbar": "Search sources and plugins",
|
||||
"searchbar": "Search sources",
|
||||
"searchResults": "Search results",
|
||||
"selectNovel": "Select Novel",
|
||||
"sources": "Sources",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "History",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "There's no next chapter",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Olvasott fejezetek törlése",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Alapértelmezett kategória",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Kategória törlése"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Küldés",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "Általános",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "összes forrás",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "Előzmények",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Áttelepítés",
|
||||
"noSummary": "Nincs összefoglaló",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Aktuális fejezethez ugrás",
|
||||
"scrollToTop": "Elejére ugrás"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Befejezett",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "Nincs következő fejezet",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Elimina capitoli letti",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Categoria predefinita",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Vuoi eliminare la categoria",
|
||||
"header": "Elimina categoria"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "Non hai categorie. Tocca il pulsante Modifica per crearne una per organizzare la tua libreria"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -308,7 +317,7 @@
|
||||
"downloads": "Downloads",
|
||||
"edit": "Modifica",
|
||||
"example": "Example",
|
||||
"filter": "Filtra",
|
||||
"filter": "Filtra",
|
||||
"globally": "globale",
|
||||
"install": "Installa",
|
||||
"imported": "Imported",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Invia",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "Generale",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "Tutte le fonti",
|
||||
"searchIn": "Cerca una novel in"
|
||||
"searchIn": "Cerca una novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "Cronologia",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migra",
|
||||
"noSummary": "Nessuna trama",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scorri al capitolo corrente",
|
||||
"scrollToTop": "Scorri in cima"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finito",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "Non c'è un capitolo successivo",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "デフォルトカテゴリー",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "カテゴリーを削除しますか?",
|
||||
"header": "カテゴリーを削除"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "カテゴリーがありません。ライブラリを整理したい場合、プラスボタンをタップしてカテゴリーを作成してください。"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "履歴",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "移行",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "次の章はありません",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"aboutScreen": {
|
||||
"website": "Website",
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"helpTranslate": "Help translate",
|
||||
"plugins": "Plugins",
|
||||
"version": "Version",
|
||||
"whatsNew": "What's new"
|
||||
"website": "웹사이트",
|
||||
"discord": "디스코드",
|
||||
"github": "깃허브",
|
||||
"helpTranslate": "번역에 도움을 주세요.",
|
||||
"plugins": "플러그인",
|
||||
"version": "버전 정보",
|
||||
"whatsNew": "새로운 소식"
|
||||
},
|
||||
"advancedSettings": "Advanced",
|
||||
"advancedSettings": "고급 설정",
|
||||
"advancedSettingsScreen": {
|
||||
"cachedNovelsDeletedToast": "Cached novels deleted",
|
||||
"chapterInsertFailed": "chapter insert failed",
|
||||
@@ -19,10 +19,11 @@
|
||||
"clearUpdatesTab": "Clear updates tab",
|
||||
"clearUpdatesWarning": "Updates tab will be cleared.",
|
||||
"clearupdatesTabDesc": "Clears chapter entries in updates tab",
|
||||
"dataManagement": "Data Management",
|
||||
"dataManagement": "데이터 관리",
|
||||
"deleteReadChapters": "Delete read chapters",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"disabled": "Disabled",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "비활성화",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
"importEpub": "Import EPUB",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,14 +242,14 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
"plugins": "Plugins",
|
||||
"removeFromLibrary": "Removed from library",
|
||||
"repositories": "Repositories",
|
||||
"searchbar": "Search sources and plugins",
|
||||
"searchbar": "Search sources",
|
||||
"searchResults": "Search results",
|
||||
"selectNovel": "Select Novel",
|
||||
"sources": "Sources",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Default category",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Do you wish to delete category",
|
||||
"header": "Delete category"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "You have no categories. Tap the Edit button to create one for organizing your library"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "General",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "all sources",
|
||||
"searchIn": "Search a novel in"
|
||||
"searchIn": "Search a novel in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "History",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migrate",
|
||||
"noSummary": "No summary",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Finished",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "There's no next chapter",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dataManagement": "Data Management",
|
||||
"deleteReadChapters": "Verwijder gelezen hoofdstukken",
|
||||
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"disabled": "Disabled",
|
||||
"dnsOverHttps": "DNS over HTTPS (DoH)",
|
||||
"dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
|
||||
@@ -37,7 +38,10 @@
|
||||
"recreateDBIndexesToast": "Recreated DB indexes",
|
||||
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
|
||||
"recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
|
||||
"restartRequired": "Restart LNReader to apply this change."
|
||||
"restartRequired": "Restart LNReader to apply this change.",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsDescription": "Create a diagnostic report with app, device, plugin, exception, and app-accessible logcat information.",
|
||||
"shareCrashLogsFailed": "Failed to create crash logs."
|
||||
},
|
||||
"appearance": "Appearance",
|
||||
"appearanceScreen": {
|
||||
@@ -172,13 +176,13 @@
|
||||
"description": "Choose what to include in this backup.",
|
||||
"selectAll": "Select all",
|
||||
"library": "Library data",
|
||||
"libraryDescription": "Novels, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"libraryDescription": "Novels, covers, chapter lists, reading progress, bookmarks, and categories.",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "App, reader, and update preferences.",
|
||||
"plugins": "Plugins",
|
||||
"pluginsDescription": "Installed source plugin files.",
|
||||
"downloadedFiles": "Downloaded chapters and covers",
|
||||
"downloadedFilesDescription": "Downloaded chapter content and saved novel covers. This may be large."
|
||||
"downloadedFiles": "Downloaded chapters",
|
||||
"downloadedFilesDescription": "Downloaded chapter content for offline reading. This may be large."
|
||||
},
|
||||
"preparingData": "Preparing Data",
|
||||
"preparingSelectedFiles": "Preparing selected files",
|
||||
@@ -238,7 +242,7 @@
|
||||
"openPluginSettings": "Open settings for %{name}",
|
||||
"openWebsite": "Open the website for %{name}",
|
||||
"pinSource": "Pin %{name}",
|
||||
"pinnedPlugins": "Pinned",
|
||||
"pinnedPlugins": "Pinned plugins",
|
||||
"pluginDetails": "Plugin details",
|
||||
"pluginIcon": "plugin icon",
|
||||
"pluginNotInstalled": "This plugin is not installed",
|
||||
@@ -274,6 +278,7 @@
|
||||
"cantDeleteDefault": "You can't delete the default category",
|
||||
"default": "Default",
|
||||
"defaultCategory": "Standaard categorie",
|
||||
"alwaysAsk": "Always ask",
|
||||
"deleteModal": {
|
||||
"desc": "Wilt u deze categorie verwijderen",
|
||||
"header": "Categorie verwijderen"
|
||||
@@ -287,7 +292,11 @@
|
||||
"setModalEmptyMsg": "Je hebt geen categorieën. Tik op de plus-knop om er een te maken om je bibliotheek te organiseren"
|
||||
},
|
||||
"repositories": {
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started."
|
||||
"disable": "Disable",
|
||||
"disableTitle": "Disable repository?",
|
||||
"disableWarning": "LNReader will stop checking %{name}. Its plugins will be hidden unless another enabled repository provides them. Installed plugins will remain installed.",
|
||||
"emptyMsg": "You have no repositories. Add your first plugin repository to get started.",
|
||||
"toggle": "Toggle %{name} repository"
|
||||
},
|
||||
"common": {
|
||||
"about": "About",
|
||||
@@ -340,7 +349,12 @@
|
||||
"submit": "Submit",
|
||||
"loading": "Loading ...",
|
||||
"moreOptions": "More options",
|
||||
"warning": "Warning"
|
||||
"warning": "Warning",
|
||||
"code": "Code",
|
||||
"custom_code": "Custom Code",
|
||||
"textToReplace": "Text to replace",
|
||||
"replaceWith": "Replace with",
|
||||
"replaceText": "Replace text"
|
||||
},
|
||||
"webview": {
|
||||
"refresh": "Refresh",
|
||||
@@ -374,10 +388,19 @@
|
||||
"pluginNotFound": "Plugin not found!",
|
||||
"removeDownloadsWarning": "Are you sure? All downloaded chapters will be deleted."
|
||||
},
|
||||
"taskQueue": {
|
||||
"cancelTaskAction": "Cancel task",
|
||||
"cancelTaskTitle": "Cancel task?",
|
||||
"cancelTaskConfirmation": "Are you sure you want to cancel %{task}?",
|
||||
"keepTaskAction": "Keep task"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "An Unexpected Error Occurred",
|
||||
"description": "The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.",
|
||||
"description": "The application ran into an unexpected error. Please share the crash logs in our Discord support channel.",
|
||||
"copyStackTrace": "Copy stack trace",
|
||||
"shareCrashLogs": "Share crash logs",
|
||||
"shareCrashLogsFailed": "Could not share crash logs. The stack trace was copied instead.",
|
||||
"shareCrashLogsFailedWithoutCopy": "Could not share crash logs or copy the stack trace.",
|
||||
"restart": "Restart the application"
|
||||
},
|
||||
"generalSettings": "Algemeen",
|
||||
@@ -441,7 +464,10 @@
|
||||
},
|
||||
"globalSearch": {
|
||||
"allSources": "alle bronnen",
|
||||
"searchIn": "Zoek een roman in"
|
||||
"searchIn": "Zoek een roman in",
|
||||
"openNovel": "Open novel",
|
||||
"openCopiedNovel": "Open copied novel",
|
||||
"noSourceForUrl": "No source found for this URL"
|
||||
},
|
||||
"history": "Geschiedenis",
|
||||
"historyScreen": {
|
||||
@@ -591,9 +617,11 @@
|
||||
"searchByName": "Search by Chapter Name",
|
||||
"searchByNameDescription": "Turn off to search by chapter number."
|
||||
},
|
||||
"loadFailed": "Unable to load novel",
|
||||
"migrate": "Migreer",
|
||||
"noSummary": "Geen samenvatting",
|
||||
"noCoverFound": "No cover found",
|
||||
"notFound": "Novel not found on the source",
|
||||
"progress": "Progress %{progress} %",
|
||||
"readChaptersDeleted": "Read chapters deleted",
|
||||
"startReadingChapters": "Start reading %{name}",
|
||||
@@ -662,7 +690,7 @@
|
||||
"scrollToCurrentChapter": "Scroll to current chapter",
|
||||
"scrollToTop": "Scroll to top"
|
||||
},
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
|
||||
"finished": "Voltooid",
|
||||
"nextChapter": "Next: %{name}",
|
||||
"noNextChapter": "Er is geen volgend hoofdstuk",
|
||||
@@ -780,6 +808,28 @@
|
||||
"other": "%{count} days"
|
||||
}
|
||||
},
|
||||
"customCodeSettings": {
|
||||
"textManipulation": "Text Manipulation",
|
||||
"codeSnippets": "Code Snippets",
|
||||
"cssSnippets": "CSS Snippets",
|
||||
"javascriptSnippets": "JavaScript Snippets",
|
||||
"createCSSSnippet": "Create CSS Snippet",
|
||||
"addCssCode": "Add custom CSS code that applies to all chapters",
|
||||
"createJSSnippet": "Create JS Snippet",
|
||||
"addJavascriptCode": "Add custom JavaScript code that applies to all chapters",
|
||||
"noCodeSnippets": "No code snippets",
|
||||
"renameSnippet": "Rename Snippet",
|
||||
"yourCodeHere": "Your code here",
|
||||
"editReplace": "Edit Replace/Remove",
|
||||
"enterAMatch": "Enter a match pattern",
|
||||
"enterAReplace": "Enter a replace value",
|
||||
"itemAlreadyExists": "Item already exists",
|
||||
"replace": "Replace",
|
||||
"removeText": "Remove Text",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetUpdated": "Snippet updated",
|
||||
"imported": "Imported"
|
||||
},
|
||||
"genreStats": {
|
||||
"title": "Genre statistics",
|
||||
"totalNovels": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user