Compare commits

...

68 Commits
5.1.0 ... 5.2.0

Author SHA1 Message Date
Athou
d5cf690703 release 5.2.0 2024-09-18 17:21:10 +02:00
Athou
191574dace manually load more entries if needed when pressing a keyboard shortcut to go to the next entry (#1557) 2024-09-18 16:23:53 +02:00
renovate[bot]
ee7c6792c9 Update dependency @types/react to ^18.3.7 2024-09-17 11:39:38 +00:00
renovate[bot]
e2962dc2eb Update dependency vite to ^5.4.6 2024-09-16 23:19:58 +00:00
Jérémie Panzer
8c335cb8fd Merge pull request #1555 from victorhck/master
Update messages.po
2024-09-16 18:40:24 +02:00
Victorhck
461c18a591 Update messages.po
Keep in spanish the same name for Shift Key
2024-09-16 16:59:56 +02:00
Jérémie Panzer
363837ab26 we no longer need this file thanks to quarkus dev services 2024-09-16 15:55:06 +02:00
renovate[bot]
a184485421 Update dependency @types/react to ^18.3.6 2024-09-16 11:44:00 +00:00
renovate[bot]
f992c3f1a6 Lock file maintenance 2024-09-16 01:01:23 +00:00
Jérémie Panzer
3219a9e313 Merge pull request #1554 from Athou/renovate/biomejs-biome-1.9.x
Update dependency @biomejs/biome to v1.9.1
2024-09-15 21:39:44 +02:00
renovate[bot]
4717c31058 Update dependency @biomejs/biome to v1.9.1 2024-09-15 18:33:30 +00:00
Athou
693844828b comment tweak 2024-09-15 12:18:56 +02:00
renovate[bot]
ef4b479638 Update quarkus.version to v3.14.4 2024-09-14 16:38:57 +00:00
Athou
8eefb1bcfb add http cache to avoid fetching feeds too often (#1431) 2024-09-14 14:00:15 +02:00
Athou
ada9a5039b add support for all charsets in native mode 2024-09-14 07:56:36 +02:00
Athou
cca2d49cc3 Revert "reduce artifact size by using a smaller library for charset detection" because juniversalchardet doesn't support as many charsets as icu4j 2024-09-13 23:40:13 +02:00
Athou
f4a43e9950 only compute rtl once by storing it in the database on fetch 2024-09-13 22:25:02 +02:00
Athou
9a89b39b62 fix formatting 2024-09-13 21:57:21 +02:00
Jérémie Panzer
2dba844b6c Merge pull request #1552 from victorhck/master
uptade and improve Spanish translation
2024-09-13 21:56:49 +02:00
renovate[bot]
3101dc91de Update dependency vitest to ^2.1.1 2024-09-13 15:51:45 +00:00
Victorhck
83cacd97f3 fix typo Spanish messages.po 2024-09-13 17:19:44 +02:00
Victorhck
aa179c21f8 uptade and improve Spanish translation 2024-09-13 17:17:35 +02:00
Athou
31cf4d8bb2 use our own bidi detector to drop 10Mb from gwt 2024-09-13 16:05:21 +02:00
Athou
19bcc2c0da reduce artifact size by using a smaller library for charset detection 2024-09-13 14:48:24 +02:00
renovate[bot]
ca803ff7ce Update dependency vite to ^5.4.5 2024-09-13 09:42:41 +00:00
Athou
0e26d975aa we don't need quarkus-extension-processor at runtime 2024-09-13 10:55:02 +02:00
Athou
86a3cb67f2 exclude unused database dependencies from final artifact 2024-09-13 10:20:19 +02:00
Jérémie Panzer
6297463445 Merge pull request #1551 from Athou/renovate/com.microsoft.playwright-playwright-1.x
Update dependency com.microsoft.playwright:playwright to v1.47.0
2024-09-13 03:56:06 +02:00
renovate[bot]
1a3a3076b1 Update dependency com.microsoft.playwright:playwright to v1.47.0 2024-09-13 00:06:46 +00:00
Jérémie Panzer
7fb9cfeaf1 Merge pull request #1550 from Athou/renovate/vitest-monorepo
Update dependency vitest to ^2.1.0
2024-09-12 20:46:39 +02:00
renovate[bot]
5c7dbe6304 Update dependency vitest to ^2.1.0 2024-09-12 16:01:52 +00:00
Jérémie Panzer
c41fd9216a Merge pull request #1549 from Athou/renovate/fontsource-monorepo
Update dependency @fontsource/open-sans to ^5.1.0
2024-09-12 16:42:21 +02:00
Jérémie Panzer
91a9ad79f0 Merge pull request #1548 from Athou/renovate/biomejs-biome-1.x
Update dependency @biomejs/biome to v1.9.0
2024-09-12 16:42:10 +02:00
renovate[bot]
906458dc96 Update dependency @fontsource/open-sans to ^5.1.0 2024-09-12 14:21:15 +00:00
renovate[bot]
2f4fddf539 Update dependency @biomejs/biome to v1.9.0 2024-09-12 14:21:09 +00:00
renovate[bot]
a8157143b9 Update dependency io.quarkus.platform:quarkus-bom to v3.14.3 2024-09-11 14:55:31 +00:00
Athou
92576e28e9 fix tests incorrectly always running with h2 2024-09-11 16:49:02 +02:00
Athou
a6e34a2960 no need to repeat the plugin, we can just use the variable 2024-09-11 16:35:08 +02:00
renovate[bot]
306cf7aab7 Update dependency vite to ^5.4.4 2024-09-11 12:55:32 +00:00
Athou
f3b806686d help during development by showing typescript errors for the whole project 2024-09-11 14:54:01 +02:00
Athou
6634cfb991 load from old settings if new settings are not found to smooth transition 2024-09-11 07:52:41 +02:00
Athou
9930bb68b2 rename variable because it now contains a duration 2024-09-10 20:47:08 +02:00
Athou
37722131e5 remove warning about deprecated findDOMNode 2024-09-10 20:16:24 +02:00
Athou
5f2d213419 move other settings to localSettings too 2024-09-10 20:16:24 +02:00
Athou
e119941762 add option to keep some entries above the selected entry when scrolling 2024-09-10 20:16:24 +02:00
Athou
ba496c1395 no longer fetch feeds without subscriptions 2024-09-10 11:16:25 +02:00
renovate[bot]
9c3fc84542 Update dependency react-router-dom to ^6.26.2 2024-09-09 17:31:13 +00:00
Jérémie Panzer
b017ce936a Merge pull request #1546 from Athou/renovate/typescript-5.x
Update dependency typescript to ^5.6.2
2024-09-09 19:30:40 +02:00
renovate[bot]
d696b0581b Update dependency typescript to ^5.6.2 2024-09-09 17:03:23 +00:00
Athou
e4b2880f2b funding links tweaks 2024-09-09 18:56:04 +02:00
renovate[bot]
e071049969 Lock file maintenance 2024-09-09 03:25:09 +00:00
renovate[bot]
5e1f592951 Lock file maintenance 2024-09-09 00:21:56 +00:00
renovate[bot]
231f82da28 Update dependency @fontsource/open-sans to ^5.0.30 2024-09-08 04:53:38 +00:00
Athou
9a28bc7334 release 5.1.1 2024-09-06 11:44:37 +02:00
Athou
00907e92ff indicate that we actually set a boolean value (#1544) 2024-09-06 10:12:05 +02:00
Athou
5669798881 tweak bug report template 2024-09-06 09:09:14 +02:00
Athou
f3a62a5f75 always show current feed/category (#1543) 2024-09-06 09:09:14 +02:00
Jérémie Panzer
3b20ed222c Merge pull request #1542 from Athou/renovate/debian-12.x
Update debian Docker tag to v12.7
2024-09-05 04:42:59 +02:00
renovate[bot]
1f40f3f59c Update debian Docker tag to v12.7 2024-09-05 02:04:26 +00:00
Athou
a8d890524f fix doc 2024-09-04 20:22:24 +02:00
renovate[bot]
b635799e80 Update quarkus.version to v3.14.2 2024-09-04 17:32:46 +00:00
renovate[bot]
50ea66620d Update dependency tss-react to ^4.9.13 2024-09-04 07:10:20 +00:00
Athou
46581d0e22 quarkus has an extension that pulls both junit and mockito 2024-09-04 09:09:28 +02:00
renovate[bot]
a3562867a6 Update dependency vite to ^5.4.3 2024-09-03 19:41:15 +00:00
Athou
c0117ada93 add a link to the feed of the GitHub release page in the README 2024-09-03 19:38:55 +02:00
Jérémie Panzer
a3dcb2c03e Merge pull request #1540 from dai/master
Update messages.po
2024-09-03 10:45:13 +02:00
dai
8f8aaa5a1d Update messages.po
Updated and added Japanese language file.
2024-09-03 16:31:15 +09:00
renovate[bot]
85482422fd Lock file maintenance 2024-09-02 15:50:13 +00:00
83 changed files with 2259 additions and 1117 deletions

7
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,5 @@
github: [athou]
custom: ['https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed&currency_code=EUR']
github: [ athou ]
custom: [
'https://github.com/sponsors/Athou',
'https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed&currency_code=EUR'
]

View File

@@ -25,7 +25,8 @@ If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- CommaFeed version (or "commafeed.com"): 3.2.1
- commafeed.com or self-hosted:
- If self-hosted, CommaFeed version [e.g. 5.1.0 (a3dcb2c)]:
- Browser [e.g. chrome, firefox]:
- Device [e.g. desktop, mobile]:

View File

@@ -1,5 +1,17 @@
# Changelog
## [5.2.0]
- Added an option to keep a number of entries above the selected entry when scrolling
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour)
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
## [5.1.1]
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543)
## [5.1.0]
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)

View File

@@ -110,6 +110,10 @@ All other Quarkus settings can be found [here](https://quarkus.io/guides/all-con
When started, the server will listen on http://localhost:8082.
The default user is `admin` and the default password is `admin`.
### Updates
When CommaFeed is up and running, you can subscribe to [this feed](https://github.com/Athou/commafeed/releases.atom) to be notified of new releases.
### Memory management (`jvm` package only)
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
"$schema": "https://biomejs.dev/schemas/1.9.1/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 4,

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@fontsource/open-sans": "^5.0.29",
"@fontsource/open-sans": "^5.1.0",
"@lingui/core": "^4.11.4",
"@lingui/macro": "^4.11.4",
"@lingui/react": "^4.11.4",
@@ -45,22 +45,20 @@
"react-icons": "^5.3.0",
"react-infinite-scroller": "^1.2.6",
"react-redux": "^9.1.2",
"react-router-dom": "^6.26.1",
"react-router-dom": "^6.26.2",
"react-swipeable": "^7.0.1",
"redoc": "^2.1.5",
"throttle-debounce": "^5.0.2",
"tinycon": "^0.6.8",
"tss-react": "^4.9.12",
"use-local-storage": "^3.0.0",
"vite-plugin-biome": "^1.0.12",
"tss-react": "^4.9.13",
"websocket-heartbeat-js": "^1.1.3"
},
"devDependencies": {
"@biomejs/biome": "^1.8.3",
"@biomejs/biome": "^1.9.1",
"@lingui/cli": "^4.11.4",
"@lingui/vite-plugin": "^4.11.4",
"@types/mousetrap": "^1.6.15",
"@types/react": "^18.3.5",
"@types/react": "^18.3.7",
"@types/react-dom": "^18.3.0",
"@types/react-helmet": "^6.1.11",
"@types/react-infinite-scroller": "^1.2.5",
@@ -69,11 +67,13 @@
"@types/tinycon": "^0.6.5",
"@vitejs/plugin-react": "^4.3.1",
"babel-plugin-macros": "^3.1.0",
"jsdom": "^25.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"typescript": "^5.5.4",
"vite": "^5.4.2",
"typescript": "^5.6.2",
"vite": "^5.4.6",
"vite-plugin-checker": "^0.8.0",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.0.5",
"vitest": "^2.1.1",
"vitest-mock-extended": "^2.0.2"
}
}

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.1.0</version>
<version>5.2.0</version>
</parent>
<artifactId>commafeed-client</artifactId>
<name>CommaFeed Client</name>

View File

@@ -166,22 +166,34 @@ export const selectEntry = createAppAsyncThunk(
})
if (arg.scrollToEntry) {
const viewMode = state.user.localSettings.viewMode
const entryIndex = state.entries.entries.indexOf(entry)
const entriesToKeepOnTopWhenScrolling =
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
const entryElement = document.getElementById(Constants.dom.entryId(entry))
if (entryElement) {
const entryElementToScrollTo = document.getElementById(Constants.dom.entryId(entryToScrollTo))
if (entryElement && entryElementToScrollTo) {
const scrollMode = state.user.settings?.scrollMode
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement)
const entryEntirelyVisible =
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
const scrollSpeed = state.user.settings?.scrollSpeed
const margin = viewMode === "detailed" ? 8 : 3
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
scrollToEntry(entryElementToScrollTo, margin, scrollSpeed, () =>
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false))
)
}
}
}
}
)
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
const offset = (header?.bottom ?? 0) + 3
const offset = (header?.bottom ?? 0) + margin
scrollToWithCallback({
options: {
top: entryElement.offsetTop - offset,

View File

@@ -3,7 +3,8 @@ import { entriesSlice } from "app/entries/slice"
import { redirectSlice } from "app/redirect/slice"
import { serverSlice } from "app/server/slice"
import { treeSlice } from "app/tree/slice"
import { userSlice } from "app/user/slice"
import type { LocalSettings } from "app/types"
import { initialLocalSettings, userSlice } from "app/user/slice"
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
export const reducers = {
@@ -14,7 +15,36 @@ export const reducers = {
user: userSlice.reducer,
}
export const store = configureStore({ reducer: reducers })
const loadLocalSettings = (): LocalSettings => {
const json = localStorage.getItem("commafeed-local-settings")
if (json) {
return JSON.parse(json)
}
// load old settings
const viewMode = localStorage.getItem("view-mode")
const sidebarWidth = localStorage.getItem("sidebar-width")
const announcementHash = localStorage.getItem("announcement-hash")
return {
...initialLocalSettings,
viewMode: viewMode ? JSON.parse(viewMode) : initialLocalSettings.viewMode,
sidebarWidth: sidebarWidth ? JSON.parse(sidebarWidth) : initialLocalSettings.sidebarWidth,
announcementHash: announcementHash ? JSON.parse(announcementHash) : initialLocalSettings.announcementHash,
}
}
export const store = configureStore({
reducer: reducers,
preloadedState: {
user: {
localSettings: loadLocalSettings(),
},
},
})
store.subscribe(() => {
const localSettings = store.getState().user.localSettings
localStorage.setItem("commafeed-local-settings", JSON.stringify(localSettings))
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

View File

@@ -243,6 +243,7 @@ export interface Settings {
customJs?: string
scrollSpeed: number
scrollMode: ScrollMode
entriesToKeepOnTopWhenScrolling: number
starIconDisplayMode: IconDisplayMode
externalLinkIconDisplayMode: IconDisplayMode
markAllAsReadConfirmation: boolean
@@ -253,6 +254,12 @@ export interface Settings {
sharingSettings: SharingSettings
}
export interface LocalSettings {
viewMode: ViewMode
sidebarWidth: number
announcementHash: string
}
export interface StarRequest {
id: string
feedId: number

View File

@@ -1,9 +1,10 @@
import { t } from "@lingui/macro"
import { showNotification } from "@mantine/notifications"
import { createSlice, isAnyOf } from "@reduxjs/toolkit"
import type { Settings, UserModel } from "app/types"
import { type PayloadAction, createSlice, isAnyOf } from "@reduxjs/toolkit"
import type { LocalSettings, Settings, UserModel, ViewMode } from "app/types"
import {
changeCustomContextMenu,
changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode,
changeLanguage,
changeMarkAllAsReadConfirmation,
@@ -25,16 +26,35 @@ import {
interface UserState {
settings?: Settings
localSettings: LocalSettings
profile?: UserModel
tags?: string[]
}
const initialState: UserState = {}
export const initialLocalSettings: LocalSettings = {
viewMode: "detailed",
sidebarWidth: 360,
announcementHash: "no-hash",
}
const initialState: UserState = {
localSettings: initialLocalSettings,
}
export const userSlice = createSlice({
name: "user",
initialState,
reducers: {},
reducers: {
setViewMode: (state, action: PayloadAction<ViewMode>) => {
state.localSettings.viewMode = action.payload
},
setSidebarWidth: (state, action: PayloadAction<number>) => {
state.localSettings.sidebarWidth = action.payload
},
setAnnouncementHash: (state, action: PayloadAction<string>) => {
state.localSettings.announcementHash = action.payload
},
},
extraReducers: builder => {
builder.addCase(reloadSettings.fulfilled, (state, action) => {
state.settings = action.payload
@@ -73,6 +93,10 @@ export const userSlice = createSlice({
if (!state.settings) return
state.settings.scrollMode = action.meta.arg
})
builder.addCase(changeEntriesToKeepOnTopWhenScrolling.pending, (state, action) => {
if (!state.settings) return
state.settings.entriesToKeepOnTopWhenScrolling = action.meta.arg
})
builder.addCase(changeStarIconDisplayMode.pending, (state, action) => {
if (!state.settings) return
state.settings.starIconDisplayMode = action.meta.arg
@@ -112,6 +136,7 @@ export const userSlice = createSlice({
changeShowRead.fulfilled,
changeScrollMarks.fulfilled,
changeScrollMode.fulfilled,
changeEntriesToKeepOnTopWhenScrolling.fulfilled,
changeStarIconDisplayMode.fulfilled,
changeExternalLinkIconDisplayMode.fulfilled,
changeMarkAllAsReadConfirmation.fulfilled,
@@ -130,3 +155,5 @@ export const userSlice = createSlice({
)
},
})
export const { setViewMode, setSidebarWidth, setAnnouncementHash } = userSlice.actions

View File

@@ -43,6 +43,14 @@ export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scro
if (!settings) return
client.user.saveSettings({ ...settings, scrollMode })
})
export const changeEntriesToKeepOnTopWhenScrolling = createAppAsyncThunk(
"settings/entriesToKeepOnTopWhenScrolling",
(entriesToKeepOnTopWhenScrolling: number, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, entriesToKeepOnTopWhenScrolling })
}
)
export const changeStarIconDisplayMode = createAppAsyncThunk(
"settings/starIconDisplayMode",
(starIconDisplayMode: IconDisplayMode, thunkApi) => {

View File

@@ -1,9 +1,9 @@
import { Trans } from "@lingui/macro"
import { Box, Dialog, Text } from "@mantine/core"
import { useAppSelector } from "app/store"
import { useAppDispatch, useAppSelector } from "app/store"
import { setAnnouncementHash } from "app/user/slice"
import { Content } from "components/content/Content"
import { useAsync } from "react-async-hook"
import useLocalStorage from "use-local-storage"
const sha256Hex = async (input: string | undefined) => {
const data = new TextEncoder().encode(input)
@@ -15,10 +15,11 @@ const sha256Hex = async (input: string | undefined) => {
export function AnnouncementDialog() {
const announcement = useAppSelector(state => state.server.serverInfos?.announcement)
const announcementHash = useAsync(sha256Hex, [announcement]).result
const [localStorageHash, setLocalStorageHash] = useLocalStorage("announcement-hash", "no-hash")
const existingAnnouncementHash = useAppSelector(state => state.user.localSettings.announcementHash)
const dispatch = useAppDispatch()
const opened = !!announcementHash && announcementHash !== localStorageHash
const onClosed = () => setLocalStorageHash(announcementHash)
const opened = !!announcementHash && announcementHash !== existingAnnouncementHash
const onClosed = () => announcementHash && dispatch(setAnnouncementHash(announcementHash))
if (!announcement) return null
return (

View File

@@ -20,7 +20,6 @@ import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
import { Loader } from "components/Loader"
import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMousetrap } from "hooks/useMousetrap"
import { useViewMode } from "hooks/useViewMode"
import { useEffect } from "react"
import { useContextMenu } from "react-contexify"
import InfiniteScroll from "react-infinite-scroller"
@@ -38,7 +37,7 @@ export function FeedEntries() {
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
const { viewMode } = useViewMode()
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
const dispatch = useAppDispatch()
const { openLinkInBackgroundTab } = useBrowserExtension()
@@ -129,28 +128,36 @@ export function FeedEntries() {
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
useMousetrap("r", async () => await dispatch(reloadEntries()))
useMousetrap(
"j",
async () =>
await dispatch(
selectNextEntry({
expand: true,
markAsRead: true,
scrollToEntry: true,
})
)
)
useMousetrap(
"n",
async () =>
await dispatch(
selectNextEntry({
expand: false,
markAsRead: false,
scrollToEntry: true,
})
)
)
useMousetrap("j", async () => {
// load more entries if needed
// this can happen if the last entry is too large to fit on the screen and the infinite loader doesn't trigger
if (hasMore && !loading && selectedEntry === entries[entries.length - 1]) {
await dispatch(loadMoreEntries())
}
await dispatch(
selectNextEntry({
expand: true,
markAsRead: true,
scrollToEntry: true,
})
)
})
useMousetrap("n", async () => {
// load more entries if needed
// this can happen if the last entry is too large to fit on the screen and the infinite loader doesn't trigger
if (hasMore && !loading && selectedEntry === entries[entries.length - 1]) {
await dispatch(loadMoreEntries())
}
await dispatch(
selectNextEntry({
expand: false,
markAsRead: false,
scrollToEntry: true,
})
)
})
useMousetrap(
"k",
async () =>

View File

@@ -5,7 +5,6 @@ import type { Entry, ViewMode } from "app/types"
import { FeedEntryCompactHeader } from "components/content/header/FeedEntryCompactHeader"
import { FeedEntryHeader } from "components/content/header/FeedEntryHeader"
import { useMobile } from "hooks/useMobile"
import { useViewMode } from "hooks/useViewMode"
import type React from "react"
import { useSwipeable } from "react-swipeable"
import { tss } from "tss"
@@ -95,7 +94,7 @@ const useStyles = tss
})
export function FeedEntry(props: FeedEntryProps) {
const { viewMode } = useViewMode()
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
const { classes, cx } = useStyles({
read: props.entry.read,
expanded: props.expanded,

View File

@@ -14,7 +14,7 @@ import { client } from "app/client"
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import type { ViewMode } from "app/types"
import { useViewMode } from "hooks/useViewMode"
import { setViewMode } from "app/user/slice"
import { type ReactNode, useState } from "react"
import {
TbChartLine,
@@ -92,9 +92,9 @@ const viewModeData: ViewModeControlItem[] = [
export function ProfileMenu(props: ProfileMenuProps) {
const [opened, setOpened] = useState(false)
const { viewMode, setViewMode } = useViewMode()
const profile = useAppSelector(state => state.user.profile)
const admin = useAppSelector(state => state.user.profile?.admin)
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
const dispatch = useAppDispatch()
const { colorScheme, setColorScheme } = useMantineColorScheme()
@@ -156,7 +156,7 @@ export function ProfileMenu(props: ProfileMenuProps) {
orientation="vertical"
data={viewModeData}
value={viewMode}
onChange={e => setViewMode(e as ViewMode)}
onChange={e => dispatch(setViewMode(e as ViewMode))}
mb="xs"
/>

View File

@@ -1,3 +1,4 @@
import { NumberFormatter } from "@mantine/core"
import type { MetricGauge } from "app/types"
interface MeterProps {
@@ -5,5 +6,5 @@ interface MeterProps {
}
export function Gauge(props: MeterProps) {
return <span>{props.gauge.value}</span>
return <NumberFormatter value={props.gauge.value} thousandSeparator />
}

View File

@@ -1,12 +1,13 @@
import { Trans, msg } from "@lingui/macro"
import { useLingui } from "@lingui/react"
import { Divider, Group, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
import { Divider, Group, NumberInput, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
import { Constants } from "app/constants"
import { useAppDispatch, useAppSelector } from "app/store"
import type { IconDisplayMode, ScrollMode, SharingSettings } from "app/types"
import {
changeCustomContextMenu,
changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode,
changeLanguage,
changeMarkAllAsReadConfirmation,
@@ -29,6 +30,7 @@ export function DisplaySettings() {
const showRead = useAppSelector(state => state.user.settings?.showRead)
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
const entriesToKeepOnTop = useAppSelector(state => state.user.settings?.entriesToKeepOnTopWhenScrolling)
const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
@@ -145,6 +147,14 @@ export function DisplaySettings() {
</Group>
</Radio.Group>
<NumberInput
label={<Trans>Entries to keep above the selected entry when scrolling</Trans>}
description={<Trans>Only applies to compact, cozy and detailed modes</Trans>}
min={0}
value={entriesToKeepOnTop}
onChange={async value => await dispatch(changeEntriesToKeepOnTopWhenScrolling(+value))}
/>
<Switch
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
checked={scrollSpeed ? scrollSpeed > 0 : false}

View File

@@ -35,6 +35,21 @@ export function Tree() {
const showRead = useAppSelector(state => state.user.settings?.showRead)
const dispatch = useAppDispatch()
const isFeedDisplayed = (feed: Subscription) => {
const isCurrentFeed = source.type === "feed" && source.id === String(feed.id)
return isCurrentFeed || feed.unread > 0 || showRead
}
const isCategoryDisplayed = (category: Category): boolean => {
const isCurrentCategory = source.type === "category" && source.id === category.id
return (
isCurrentCategory ||
showRead ||
category.children.some(c => isCategoryDisplayed(c)) ||
category.feeds.some(f => isFeedDisplayed(f))
)
}
const feedClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) {
dispatch(redirectToFeedDetails(id))
@@ -97,8 +112,7 @@ export function Tree() {
)
const categoryNode = (category: Category, level = 0) => {
const unreadCount = categoryUnreadCount(category)
if (unreadCount === 0 && !showRead) return null
if (!isCategoryDisplayed(category)) return null
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
return (
@@ -107,7 +121,7 @@ export function Tree() {
type="category"
name={category.name}
icon={category.expanded ? expandedIcon : collapsedIcon}
unread={unreadCount}
unread={categoryUnreadCount(category)}
selected={source.type === "category" && source.id === category.id}
expanded={category.expanded}
level={level}
@@ -120,7 +134,7 @@ export function Tree() {
}
const feedNode = (feed: Subscription, level = 0) => {
if (feed.unread === 0 && !showRead) return null
if (!isFeedDisplayed(feed)) return null
return (
<TreeNode

View File

@@ -1,7 +0,0 @@
import type { ViewMode } from "app/types"
import useLocalStorage from "use-local-storage"
export function useViewMode() {
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("view-mode", "detailed")
return { viewMode, setViewMode }
}

View File

@@ -321,6 +321,10 @@ msgstr "دخول"
msgid "Enter your current password to change profile settings"
msgstr "أدخل كلمة المرور الحالية لتغيير إعدادات ملف التعريف"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "اوووه!"

View File

@@ -321,6 +321,10 @@ msgstr "Entra"
msgid "Enter your current password to change profile settings"
msgstr "introduïu la vostra contrasenya actual per canviar la configuració del perfil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "Al mòbil, mostra els botons d'acció a la part inferior de la pantalla"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Vaja!"

View File

@@ -321,6 +321,10 @@ msgstr "Vstupte"
msgid "Enter your current password to change profile settings"
msgstr "Zadejte své aktuální heslo pro změnu nastavení profilu"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Jejda!"

View File

@@ -321,6 +321,10 @@ msgstr "Ewch i mewn"
msgid "Enter your current password to change profile settings"
msgstr "Rhowch eich cyfrinair presennol i newid gosodiadau proffil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Wps!"

View File

@@ -321,6 +321,10 @@ msgstr ""
msgid "Enter your current password to change profile settings"
msgstr "Indtast din nuværende adgangskode for at ændre profilindstillinger"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Hovsa!"

View File

@@ -321,6 +321,10 @@ msgstr "Eintreten"
msgid "Enter your current password to change profile settings"
msgstr "Geben Sie Ihr aktuelles Passwort ein, um die Profileinstellungen zu ändern"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "Auf mobilen Geräten die Aktion-Buttons am unteren Ende des Bildschirms anzeigen"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Ups!"

View File

@@ -321,6 +321,10 @@ msgstr "Enter"
msgid "Enter your current password to change profile settings"
msgstr "Enter your current password to change profile settings"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr "Entries to keep above the selected entry when scrolling"
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr "Entry headers"
@@ -608,6 +612,10 @@ msgstr "On mobile"
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "On mobile, show action buttons at the bottom of the screen"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr "Only applies to compact, cozy and detailed modes"
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Oops!"

View File

@@ -1,25 +1,26 @@
# SPDX-FileCopyrightText: 2024 victorhck <victorhck@mailbox.org>
msgid ""
msgstr ""
"POT-Creation-Date: 2022-10-28 13:47+0200\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"X-Generator: Lokalize 24.08.0\n"
"Language: es\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"PO-Revision-Date: 2024-09-13 17:17+0200\n"
"Last-Translator: victorhck <victorhck@mailbox.org>\n"
"Language-Team: Spanish <kde-i18n-doc@kde.org>\n"
"Plural-Forms: \n"
#: src/pages/app/AboutPage.tsx
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
msgstr ""
msgstr "<0>CommaFeed es un proyecto de código abierto. El código fuente está hospedado en </0><1>GitHub</1>."
#: src/pages/app/FeedDetailsPage.tsx
msgid "<0>Complete syntax is available </0><1>here</1>."
msgstr ""
msgstr "<0>La sintaxis completa está disponible </0><1>aquí</1>."
#: src/pages/auth/RegistrationPage.tsx
msgid "<0>Have an account?</0><1>Log in!</1>"
@@ -27,7 +28,7 @@ msgstr "<0>¿Tienes una cuenta?</0><1>¡Inicia sesión!</1>"
#: src/pages/app/DonatePage.tsx
msgid "<0>Hey,</0><1>I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years now. Thanks for taking an interest in helping me continue supporting CommaFeed.</1>"
msgstr ""
msgstr "<0>Hola,</0><1>Soy Jérémie de Bélgica y he estado trabajando en CommaFeed en mi tiempo libre desde hace 10 años. Gracias por interesarte en ayudarme a seguir apoyando a CommaFeed.</1>"
#: src/pages/auth/LoginPage.tsx
msgid "<0>Need an account?</0><1>Sign up!</1>"
@@ -36,7 +37,7 @@ msgstr "<0>¿Necesitas una cuenta?</0><1>¡Regístrate!</1>"
#: src/components/header/ProfileMenu.tsx
#: src/pages/app/AboutPage.tsx
msgid "About"
msgstr "Sobre"
msgstr "Acerca de"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Actions"
@@ -44,7 +45,7 @@ msgstr "Acciones"
#: src/components/content/add/AddCategory.tsx
msgid "Add"
msgstr "Agregar"
msgstr "Añadir"
#: src/pages/app/AddPage.tsx
msgid "Add category"
@@ -70,55 +71,55 @@ msgstr "Todo"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx
msgid "Always"
msgstr ""
msgstr "Siempre"
#: src/pages/auth/PasswordRecoveryPage.tsx
msgid "An email has been sent if this address was registered. Check your inbox."
msgstr "Se ha enviado un correo electrónico si se registró esta dirección. "
msgstr "Se ha enviado un correo electrónico si esta dirección estaba registrada. Revisa tu bandeja de entrada."
#: src/components/content/add/ImportOpml.tsx
msgid "An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your data from other feed reading services."
msgstr "Un archivo opml es un archivo XML que contiene categorías y direcciones URL de fuentes. "
msgstr "Un archivo opml es un archivo XML que contiene categorías y las URL de los feeds. Puedes obtener un archivo OPML exportando tus datos desde otros servicios de lectura de feeds."
#: src/components/content/add/Subscribe.tsx
msgid "Analyze feed"
msgstr "Analizar alimentación"
msgstr "Analizar feed"
#: src/components/AnnouncementDialog.tsx
msgid "Announcement"
msgstr ""
msgstr "Anuncio"
#: src/components/settings/ProfileSettings.tsx
msgid "API key"
msgstr "clave API"
msgstr "Clave API"
#: src/pages/app/CategoryDetailsPage.tsx
msgid "Are you sure you want to delete category <0>{categoryName}</0>?"
msgstr "¿Está seguro de que desea eliminar la categoría <0>{categoryName}</0>?"
msgstr "¿Estás seguro de que deseas eliminar la categoría <0>{categoryName}</0>?"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Are you sure you want to delete user <0>{userName}</0> ?"
msgstr "¿Está seguro de que desea eliminar el usuario <0>{userName}</0> ?"
msgstr "¿Estás seguro de que deseas eliminar el usuario <0>{userName}</0> ?"
#: src/components/settings/ProfileSettings.tsx
msgid "Are you sure you want to delete your account? There's no turning back!"
msgstr "¿Está seguro de que desea eliminar su cuenta? "
msgstr "¿Estás seguro de que quieres eliminar tu cuenta? ¡No hay vuelta atrás!"
#: src/components/header/MarkAllAsReadButton.tsx
msgid "Are you sure you want to mark all entries of <0>{sourceLabel}</0> as read?"
msgstr "¿Está seguro de que desea marcar todas las entradas de <0>{sourceLabel}</0> como leídas?"
msgstr "¿Estás seguro de que deseas marcar todas las entradas de <0>{sourceLabel}</0> como leídas?"
#: src/components/header/MarkAllAsReadButton.tsx
msgid "Are you sure you want to mark entries older than {threshold} days of <0>{sourceLabel}</0> as read?"
msgstr "¿Está seguro de que desea marcar las entradas anteriores a {threshold} días de <0>{sourceLabel}</0> como leídas?"
msgstr "¿Estás seguro de que deseas marcar las entradas anteriores a {threshold} días de <0>{sourceLabel}</0> como leídas?"
#: src/pages/app/FeedDetailsPage.tsx
msgid "Are you sure you want to unsubscribe from <0>{feedName}</0>?"
msgstr "¿Está seguro de que desea darse de baja de <0>{feedName}</0>?"
msgstr "¿Estás seguro de que deseas darte de baja de <0>{feedName}</0>?"
#: src/components/header/Header.tsx
msgid "Asc"
msgstr "ASC"
msgstr "Asc"
#: src/pages/app/FeedDetailsPage.tsx
msgid "Available variables are 'title', 'content', 'url' 'author' and 'categories' and their content is converted to lower case to ease string comparison."
@@ -134,15 +135,15 @@ msgstr "Volver a iniciar sesión"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Browser extension required for Chrome"
msgstr ""
msgstr "Se requiere extensión de navegador para Chrome"
#: src/pages/app/AboutPage.tsx
msgid "Browser extention"
msgstr ""
msgstr "Extensión del navegador"
#: src/components/settings/DisplaySettings.tsx
msgid "Browser tab"
msgstr ""
msgstr "Pestaña del navegador"
#: src/components/admin/UserEdit.tsx
#: src/components/content/add/AddCategory.tsx
@@ -174,23 +175,23 @@ msgstr "Cambiar la contraseña generará una nueva clave API"
#: src/components/content/add/Subscribe.tsx
msgid "Check that the feed is working"
msgstr "Compruebe que el feed funciona"
msgstr "Comprueba que el feed funciona"
#: src/pages/app/Layout.tsx
msgid "Close menu"
msgstr ""
msgstr "Cerrar menú"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Cmd"
msgstr ""
msgstr "Cmd"
#: src/pages/app/AboutPage.tsx
msgid "CommaFeed browser extension version {browserExtensionVersion}."
msgstr ""
msgstr "Versión de la extensión del navegador CommaFeed {browserExtensionVersion}."
#: src/components/settings/ProfileSettings.tsx
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
msgstr ""
msgstr "CommaFeed es compatible con Fever API. Utilice la siguiente URL en su cliente móvil compatible con Fever. Inicie sesión con su nombre de usuario y su <0>clave API</0>."
#: src/pages/app/AboutPage.tsx
msgid "CommaFeed next unread item"
@@ -198,7 +199,7 @@ msgstr "CommaFeed siguiente elemento no leído"
#: src/pages/app/AboutPage.tsx
msgid "CommaFeed version {version} ({revision})."
msgstr ""
msgstr "Versión de CommaFeed {version} ({revision})."
#: src/components/header/ProfileMenu.tsx
msgid "Compact"
@@ -222,7 +223,7 @@ msgstr "Acogedor"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Ctrl"
msgstr ""
msgstr "Ctrl"
#: src/components/settings/ProfileSettings.tsx
msgid "Current password"
@@ -230,19 +231,19 @@ msgstr "Contraseña actual"
#: src/pages/app/SettingsPage.tsx
msgid "Custom code"
msgstr ""
msgstr "Código personalizado"
#: src/components/settings/CustomCodeSettings.tsx
msgid "Custom CSS rules that will be applied"
msgstr ""
msgstr "Reglas CSS personalizadas que se aplicarán"
#: src/components/settings/CustomCodeSettings.tsx
msgid "Custom JS code that will be executed on page load"
msgstr ""
msgstr "Código JS personalizado que se ejecutará al cargar la página"
#: src/components/header/ProfileMenu.tsx
msgid "Dark"
msgstr ""
msgstr "Oscuro"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Date created"
@@ -267,25 +268,25 @@ msgstr "Borrar usuario"
#: src/components/header/Header.tsx
msgid "Desc"
msgstr ""
msgstr "Desc"
#: src/components/header/ProfileMenu.tsx
msgid "Detailed"
msgstr ""
msgstr "Detallado"
#: src/components/header/ProfileMenu.tsx
#: src/pages/app/SettingsPage.tsx
msgid "Display"
msgstr "Pantalla"
msgstr "Mostrar"
#: src/components/header/ProfileMenu.tsx
#: src/pages/app/DonatePage.tsx
msgid "Donate"
msgstr ""
msgstr "Donar"
#: src/components/settings/ProfileSettings.tsx
msgid "Download"
msgstr "descargar"
msgstr "Descargar"
#: src/pages/app/AboutPage.tsx
msgid "Drag link to bookmark bar"
@@ -302,7 +303,7 @@ msgstr "Correo electrónico"
#: src/pages/auth/RegistrationPage.tsx
#: src/pages/auth/RegistrationPage.tsx
msgid "E-mail address"
msgstr "dirección de correo electrónico"
msgstr "Dirección de correo electrónico"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Edit user"
@@ -319,15 +320,19 @@ msgstr "Entrar"
#: src/components/settings/ProfileSettings.tsx
msgid "Enter your current password to change profile settings"
msgstr "Ingrese su contraseña actual para cambiar la configuración del perfil"
msgstr "Ingresa tu contraseña actual para cambiar la configuración del perfil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr "Entradas para mantener encima de la entrada seleccionada al desplazarse"
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
msgstr "Encabezados de las entradas"
#: src/components/Alert.tsx
msgid "Error"
msgstr ""
msgstr "Error"
#: src/pages/app/FeedDetailsPage.tsx
msgid "Example: {example}."
@@ -339,34 +344,34 @@ msgstr "Expandido"
#: src/components/settings/ProfileSettings.tsx
msgid "Export your subscriptions and categories as an OPML file that can be imported in other feed reading services"
msgstr "Exporte sus suscripciones y categorías como un archivo OPML que se puede importar en otros servicios de lectura de feeds"
msgstr "Exporta tus suscripciones y categorías como un archivo OPML que se puede importar en otros servicios de lectura de feeds"
#: src/components/header/Header.tsx
#: src/pages/WelcomePage.tsx
msgid "Extension options"
msgstr ""
msgstr "Opciones de la extensión"
#: src/components/content/add/Subscribe.tsx
msgid "Feed name"
msgstr "Nombre de alimentación"
msgstr "Nombre del feed"
#: src/components/content/add/Subscribe.tsx
#: src/components/content/add/Subscribe.tsx
#: src/pages/app/FeedDetailsPage.tsx
msgid "Feed URL"
msgstr "URL de fuente"
msgstr "URL del feed"
#: src/components/header/ProfileMenu.tsx
msgid "Fetch all my feeds now"
msgstr ""
msgstr "Obtener todos mis feeds ahora"
#: src/components/settings/ProfileSettings.tsx
msgid "Fever API"
msgstr ""
msgstr "API de Fever"
#: src/components/settings/ProfileSettings.tsx
msgid "Fever API URL"
msgstr ""
msgstr "URL de la API de Fever"
#: src/pages/app/FeedDetailsPage.tsx
msgid "Filtering expression"
@@ -394,7 +399,7 @@ msgstr "URL del feed generado"
#: src/components/content/FeedEntryContextMenu.tsx
msgid "Go to {0}"
msgstr ""
msgstr "Ir a {0}"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Go to the All view"
@@ -406,7 +411,7 @@ msgstr "Ir a la documentación de la API."
#: src/pages/app/AboutPage.tsx
msgid "Goodies"
msgstr "golosinas"
msgstr "Golosinas"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Id"
@@ -414,15 +419,15 @@ msgstr "Identificación"
#: src/pages/app/FeedDetailsPage.tsx
msgid "If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read automatically."
msgstr "Si no está vacío, una expresión que se evalúa como 'verdadero' o 'falso'. "
msgstr "Si no está vacía, una expresión que se evalúa como \"verdadera\" o \"falso\". Si es falso, las nuevas entradas de este feed se marcarán como leídas automáticamente."
#: src/components/settings/DisplaySettings.tsx
msgid "If the entry doesn't entirely fit on the screen"
msgstr ""
msgstr "Si la entrada no cabe completamente en la pantalla"
#: src/pages/app/AboutPage.tsx
msgid "If you encounter an issue, please report it on the issues page of the GitHub project."
msgstr "Si encuentra un problema, infórmelo en la página de problemas del proyecto GitHub."
msgstr "Si encuentras un problema, informa sobre ello en la página de problemas del proyecto en GitHub."
#: src/components/content/add/ImportOpml.tsx
msgid "Import"
@@ -430,7 +435,7 @@ msgstr "Importar"
#: src/components/settings/DisplaySettings.tsx
msgid "In expanded view, scrolling through entries mark them as read"
msgstr "En la vista ampliada, al desplazarse por las entradas, márquelas como leídas"
msgstr "En la vista ampliada, al desplazarse por las entradas marcarlas como leídas"
#: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx
@@ -440,7 +445,7 @@ msgstr "Mantener sin leer"
#: src/components/content/FeedEntries.tsx
#: src/pages/app/AboutPage.tsx
msgid "Keyboard shortcuts"
msgstr "atajos de teclado"
msgstr "Atajos de teclado"
#: src/components/settings/DisplaySettings.tsx
msgid "Language"
@@ -448,7 +453,7 @@ msgstr "Idioma"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Last login date"
msgstr "fecha del último inicio de sesión"
msgstr "Fecha del último inicio de sesión"
#: src/pages/app/FeedDetailsPage.tsx
msgid "Last refresh"
@@ -460,7 +465,7 @@ msgstr "Último mensaje de actualización"
#: src/components/header/ProfileMenu.tsx
msgid "Light"
msgstr ""
msgstr "Claro"
#: src/pages/app/CategoryDetailsPage.tsx
#: src/pages/app/FeedDetailsPage.tsx
@@ -492,11 +497,11 @@ msgstr "Iniciar sesión"
#: src/components/header/ProfileMenu.tsx
msgid "Logout"
msgstr "Salir"
msgstr "Cerrar sesión"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Long press"
msgstr ""
msgstr "Pulsación larga"
#: src/components/header/ProfileMenu.tsx
#: src/pages/admin/AdminUsersPage.tsx
@@ -528,7 +533,7 @@ msgstr "Métricas"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Middle click"
msgstr ""
msgstr "Clic central"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Move the page down"
@@ -552,12 +557,12 @@ msgstr "Nombre"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Navigate to a subscription by entering its name"
msgstr "Navegar a una suscripción ingresando su nombre"
msgstr "Navegar a una suscripción introduciendo su nombre"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx
msgid "Never"
msgstr ""
msgstr "Nunca"
#: src/components/settings/ProfileSettings.tsx
msgid "New password"
@@ -565,7 +570,7 @@ msgstr "Nueva contraseña"
#: src/pages/app/AboutPage.tsx
msgid "Newest first"
msgstr "más reciente primero"
msgstr "Las más recientes primero"
#: src/components/content/add/Subscribe.tsx
#: src/components/header/Header.tsx
@@ -578,7 +583,7 @@ msgstr "Próxima actualización"
#: src/pages/app/AboutPage.tsx
msgid "Next unread item bookmarklet"
msgstr "Bookmarklet del siguiente elemento no leído"
msgstr "Siguiente elemento no leído de los marcadores"
#: src/pages/app/FeedEntriesPage.tsx
msgid "No more entries"
@@ -586,7 +591,7 @@ msgstr "No más entradas"
#: src/components/content/ShareButtons.tsx
msgid "No sharing options available."
msgstr ""
msgstr "No hay opciones para compartir disponibles."
#: src/components/sidebar/TreeSearch.tsx
msgid "Nothing found"
@@ -594,19 +599,23 @@ msgstr "Nada encontrado"
#: src/pages/app/AboutPage.tsx
msgid "Oldest first"
msgstr "más antigua primero"
msgstr "Las más antiguas primero"
#: src/components/settings/DisplaySettings.tsx
msgid "On desktop"
msgstr ""
msgstr "En el escritorio"
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile"
msgstr ""
msgstr "En dispositivos móviles"
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
msgstr "En dispositivos móviles, mostrar los botones de acción en la parte inferior de la pantalla"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr "Sólo se aplica a los modos compacto, acogedor y detallado"
#: src/pages/ErrorPage.tsx
msgid "Oops!"
@@ -614,7 +623,7 @@ msgstr "¡Ups!"
#: src/components/header/Header.tsx
msgid "Open CommaFeed"
msgstr ""
msgstr "Abrir Commafeed"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Open current entry in a new tab"
@@ -631,15 +640,15 @@ msgstr "Abrir enlace"
#: src/components/content/FeedEntryContextMenu.tsx
msgid "Open link in new background tab"
msgstr ""
msgstr "Abrir enlace en una nueva pestaña en segundo plano"
#: src/components/content/FeedEntryContextMenu.tsx
msgid "Open link in new tab"
msgstr ""
msgstr "Abrir el enlace en una nueva pestaña"
#: src/pages/app/Layout.tsx
msgid "Open menu"
msgstr ""
msgstr "Abrir menú"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Open next entry"
@@ -655,20 +664,20 @@ msgstr "Abrir/cerrar entrada actual"
#: src/pages/app/AddPage.tsx
msgid "OPML"
msgstr ""
msgstr "OPML"
#: src/components/settings/ProfileSettings.tsx
msgid "OPML export"
msgstr "Exportación OPML"
msgstr "Exportar OPML"
#: src/components/content/add/ImportOpml.tsx
#: src/components/content/add/ImportOpml.tsx
msgid "OPML file"
msgstr "archivo OPML"
msgstr "Archivo OPML"
#: src/components/content/add/ImportOpml.tsx
msgid "OPML file is required"
msgstr ""
msgstr "Es necesario un archivo OPML"
#: src/pages/app/AboutPage.tsx
msgid "Order"
@@ -705,7 +714,7 @@ msgstr "Posición"
#: src/components/header/Header.tsx
msgid "Previous"
msgstr ""
msgstr "Previo"
#: src/pages/app/SettingsPage.tsx
msgid "Profile"
@@ -731,7 +740,7 @@ msgstr "API REST"
#: src/components/KeyboardShortcutsHelp.tsx
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Right click"
msgstr ""
msgstr "Clic derecho"
#: src/components/admin/UserEdit.tsx
#: src/components/settings/CustomCodeSettings.tsx
@@ -743,7 +752,7 @@ msgstr "Guardar"
#: src/components/settings/DisplaySettings.tsx
msgid "Scroll selected entry to the top of the page"
msgstr ""
msgstr "Desplazar la entrada seleccionada hasta la parte superior de la página"
#: src/components/settings/DisplaySettings.tsx
msgid "Scroll smoothly when navigating between entries"
@@ -751,7 +760,7 @@ msgstr "Desplazarse suavemente al navegar entre entradas"
#: src/components/settings/DisplaySettings.tsx
msgid "Scrolling"
msgstr ""
msgstr "Desplazarse"
#: src/components/header/Header.tsx
#: src/components/header/Header.tsx
@@ -766,15 +775,15 @@ msgstr "La búsqueda requiere al menos 3 caracteres"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Set focus on next entry without opening it"
msgstr "Establezca el foco en la siguiente entrada sin abrirla"
msgstr "Establecer el foco en la siguiente entrada sin abrirla"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Set focus on previous entry without opening it"
msgstr "Poner el foco en la entrada anterior sin abrirla"
msgstr "Establecer el foco en la entrada anterior sin abrirla"
#: src/components/header/ProfileMenu.tsx
msgid "Settings"
msgstr "Configuraciones"
msgstr "Ajustes"
#: src/app/user/slice.ts
msgid "Settings saved."
@@ -792,27 +801,27 @@ msgstr "Compartir sitios"
#: src/components/KeyboardShortcutsHelp.tsx
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Shift"
msgstr "Cambio"
msgstr "Shift"
#: src/components/settings/DisplaySettings.tsx
msgid "Show CommaFeed's own context menu on right click"
msgstr ""
msgstr "Mostrar el menú contextual propio de CommaFeed al hacer clic derecho"
#: src/components/settings/DisplaySettings.tsx
msgid "Show confirmation when marking all entries as read"
msgstr ""
msgstr "Mostrar confirmación al marcar todas las entradas como leídas"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Show entry menu (desktop)"
msgstr ""
msgstr "Mostrar menú de entrada (escritorio)"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Show entry menu (mobile)"
msgstr ""
msgstr "Mostrar menú de entrada (móvil)"
#: src/components/settings/DisplaySettings.tsx
msgid "Show external link icon"
msgstr ""
msgstr "Mostrar icono de enlace externo"
#: src/components/settings/DisplaySettings.tsx
msgid "Show feeds and categories with no unread entries"
@@ -820,23 +829,23 @@ msgstr "Mostrar feeds y categorías sin entradas no leídas"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Show keyboard shortcut help"
msgstr "Mostrar ayuda de atajo de teclado"
msgstr "Mostrar la ayuda de los atajos de teclado"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Show native menu (desktop)"
msgstr ""
msgstr "Mostrar menú nativo (escritorio)"
#: src/components/settings/DisplaySettings.tsx
msgid "Show star icon"
msgstr ""
msgstr "Mostrar el icono de la estrella"
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab favicon"
msgstr ""
msgstr "Mostrar recuento de no leídos en la pestaña favicon"
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab title"
msgstr ""
msgstr "Mostrar recuento de no leídos en el título de la pestaña"
#: src/pages/auth/RegistrationPage.tsx
#: src/pages/auth/RegistrationPage.tsx
@@ -857,7 +866,7 @@ msgstr "Espacio"
#: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/Star.tsx
msgid "Star"
msgstr "estrella"
msgstr "Estrella"
#: src/app/constants.ts
#: src/components/sidebar/Tree.tsx
@@ -872,7 +881,7 @@ msgstr "Suscribirse"
#: src/components/content/add/Subscribe.tsx
msgid "Subscribe to the feed"
msgstr "Suscríbete a la fuente"
msgstr "Suscríbete al feed"
#: src/pages/app/AboutPage.tsx
msgid "Subscribe URL"
@@ -884,7 +893,7 @@ msgstr "Éxito"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Swipe header to the left"
msgstr ""
msgstr "Desliza el encabezado hacia la izquierda"
#: src/pages/WelcomePage.tsx
msgid "Switch to dark theme"
@@ -896,7 +905,7 @@ msgstr "Cambiar a tema claro"
#: src/components/header/ProfileMenu.tsx
msgid "System"
msgstr ""
msgstr "Sistema"
#: src/components/content/FeedEntryFooter.tsx
#: src/components/content/FeedEntryFooter.tsx
@@ -905,7 +914,7 @@ msgstr "Etiquetas"
#: src/components/content/add/Subscribe.tsx
msgid "The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed will try to find the feed in the page."
msgstr "La URL de la fuente a la que desea suscribirse. "
msgstr "La URL del feed al que desea suscribirse. También puede utilizar la URL del sitio web directamente y CommaFeed intentará encontrar el feed en la página."
#: src/components/header/ProfileMenu.tsx
msgid "Theme"
@@ -913,7 +922,7 @@ msgstr "Tema"
#: src/components/settings/ProfileSettings.tsx
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
msgstr ""
msgstr "Esta es su clave API. Se puede utilizar para algunas operaciones API de solo lectura y otorga acceso a Fever API. Utilice el formulario en la parte inferior de la página para generar una nueva clave API"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Toggle read status of current entry"
@@ -921,19 +930,19 @@ msgstr "Alternar estado de lectura de la entrada actual"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Toggle sidebar"
msgstr ""
msgstr "Alternar barra lateral"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Toggle starred status of current entry"
msgstr ""
msgstr "Alternar estado destacado de la entrada actual"
#: src/pages/auth/LoginPage.tsx
msgid "Try out CommaFeed with the demo account: demo/demo"
msgstr "Pruebe CommaFeed con la cuenta demo: demo/demo"
msgstr "Prueba CommaFeed con la cuenta de demostración: demo/demo"
#: src/pages/WelcomePage.tsx
msgid "Try the demo!"
msgstr ""
msgstr "¡Prueba la demostración!"
#: src/components/header/Header.tsx
msgid "Unread"
@@ -969,8 +978,8 @@ msgstr "Sitio web"
#: src/pages/app/FeedEntriesPage.tsx
msgid "You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?"
msgstr "Todavía no tienes ninguna suscripción. "
msgstr "Aún no tienes ninguna suscripción. ¿Por qué no intentas agregar una haciendo clic en el signo + en la parte superior de la página?"
#: src/components/header/ProfileMenu.tsx
msgid "Your feeds have been queued for refresh."
msgstr ""
msgstr "Tus feeds se han puesto en cola para actualizarse."

View File

@@ -321,6 +321,10 @@ msgstr "وارد شوید"
msgid "Enter your current password to change profile settings"
msgstr "رمز عبور فعلی خود را برای تغییر تنظیمات نمایه وارد کنید"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "اوه!"

View File

@@ -321,6 +321,10 @@ msgstr ""
msgid "Enter your current password to change profile settings"
msgstr "Anna nykyinen salasanasi muuttaaksesi profiiliasetuksia"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Hups!"

View File

@@ -321,6 +321,10 @@ msgstr "Entrer"
msgid "Enter your current password to change profile settings"
msgstr "Entrez votre mot de passe actuel pour changer les paramètres du profil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr "En-têtes de l'entrée"
@@ -608,6 +612,10 @@ msgstr "Version mobile"
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "Sur mobile, afficher les boutons d'action en bas de l'écran"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Oups !"

View File

@@ -321,6 +321,10 @@ msgstr "Entra"
msgid "Enter your current password to change profile settings"
msgstr "Introduce o teu contrasinal actual para cambiar a configuración do perfil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Vaia!"

View File

@@ -321,6 +321,10 @@ msgstr ""
msgid "Enter your current password to change profile settings"
msgstr "Adja meg jelenlegi jelszavát a profilbeállítások módosításához"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Hoppá!"

View File

@@ -321,6 +321,10 @@ msgstr "Masuk"
msgid "Enter your current password to change profile settings"
msgstr "Masukkan kata sandi Anda saat ini untuk mengubah pengaturan profil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Ups!"

View File

@@ -321,6 +321,10 @@ msgstr "Invio"
msgid "Enter your current password to change profile settings"
msgstr "Inserisci la tua password attuale per modificare le impostazioni del profilo"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Ops!"

View File

@@ -9,17 +9,17 @@ msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Last-Translator: https://github.com/dai\n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/pages/app/AboutPage.tsx
msgid "<0>CommaFeed is an open-source project. Sources are hosted on </0><1>GitHub</1>."
msgstr ""
msgstr "<0>CommaFeed はオープンソースのプロジェクトです。 ソースは以下でホストされています </0><1>GitHub</1>。"
#: src/pages/app/FeedDetailsPage.tsx
msgid "<0>Complete syntax is available </0><1>here</1>."
msgstr ""
msgstr "<0>完全な syntax </0><1>こちら</1>で利用可能です。"
#: src/pages/auth/RegistrationPage.tsx
msgid "<0>Have an account?</0><1>Log in!</1>"
@@ -27,7 +27,7 @@ msgstr "<0>アカウントをお持ちですか?</0><1>ログインしてくだ
#: src/pages/app/DonatePage.tsx
msgid "<0>Hey,</0><1>I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years now. Thanks for taking an interest in helping me continue supporting CommaFeed.</1>"
msgstr ""
msgstr "<0>こんにちは、</0><1>私はベルギーのジェレミーです。私はこれまで 10 年以上、CommaFeed のオープンソースプロジェクトを無料で開発してきました。あなたの関心に感謝します。</1>"
#: src/pages/auth/LoginPage.tsx
msgid "<0>Need an account?</0><1>Sign up!</1>"
@@ -36,7 +36,7 @@ msgstr "<0>アカウントが必要ですか?</0><1>サインアップ!</1>"
#: src/components/header/ProfileMenu.tsx
#: src/pages/app/AboutPage.tsx
msgid "About"
msgstr ""
msgstr "About"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Actions"
@@ -44,7 +44,7 @@ msgstr "アクション"
#: src/components/content/add/AddCategory.tsx
msgid "Add"
msgstr "追"
msgstr "追"
#: src/pages/app/AddPage.tsx
msgid "Add category"
@@ -65,20 +65,20 @@ msgstr "管理人"
#: src/components/header/Header.tsx
#: src/components/sidebar/Tree.tsx
msgid "All"
msgstr "全員"
msgstr "すべて"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx
msgid "Always"
msgstr ""
msgstr "常に"
#: src/pages/auth/PasswordRecoveryPage.tsx
msgid "An email has been sent if this address was registered. Check your inbox."
msgstr "このアドレスが登録されていれば、メール送信されました。"
msgstr "このアドレスに確認メール送信ました。受信箱を確認してください。"
#: src/components/content/add/ImportOpml.tsx
msgid "An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your data from other feed reading services."
msgstr "opml ファイルは、フィードの URL とカテゴリを含む XML ファイルです。"
msgstr "opmlファイルは、フィードのURLとカテゴリを含むXMLファイルです。OPMLファイルは他のフィードサービスからエクスポートして取得することができます"
#: src/components/content/add/Subscribe.tsx
msgid "Analyze feed"
@@ -86,7 +86,7 @@ msgstr "フィードを分析する"
#: src/components/AnnouncementDialog.tsx
msgid "Announcement"
msgstr ""
msgstr "お知らせ"
#: src/components/settings/ProfileSettings.tsx
msgid "API key"
@@ -94,27 +94,27 @@ msgstr "APIキー"
#: src/pages/app/CategoryDetailsPage.tsx
msgid "Are you sure you want to delete category <0>{categoryName}</0>?"
msgstr "カテゴリ <0>{categoryName}</0> を削除してもよろしいですか?"
msgstr "カテゴリ <0>{categoryName}</0> を削除してもよろしいですか"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Are you sure you want to delete user <0>{userName}</0> ?"
msgstr "ユーザー <0>{userName}</0> を削除してもよろしいですか?"
msgstr "ユーザー <0>{userName}</0> を削除してもよろしいですか"
#: src/components/settings/ProfileSettings.tsx
msgid "Are you sure you want to delete your account? There's no turning back!"
msgstr "本当にアカウントを削除しますか?"
msgstr "本当にアカウントを削除しますか?元には戻せません!"
#: src/components/header/MarkAllAsReadButton.tsx
msgid "Are you sure you want to mark all entries of <0>{sourceLabel}</0> as read?"
msgstr "<0>{sourceLabel}</0> のすべてのエントリを既読にしますか?"
msgstr "<0>{sourceLabel}</0> のすべてのエントリを既読にしますか"
#: src/components/header/MarkAllAsReadButton.tsx
msgid "Are you sure you want to mark entries older than {threshold} days of <0>{sourceLabel}</0> as read?"
msgstr "<0>{sourceLabel}</0> の {threshold} 日より前のエントリを既読としてマークしてもよろしいですか?"
msgstr "<0>{sourceLabel}</0> の {threshold} 日より前のエントリを既読としてマークしてもよろしいですか"
#: src/pages/app/FeedDetailsPage.tsx
msgid "Are you sure you want to unsubscribe from <0>{feedName}</0>?"
msgstr "<0>{feedName}</0> の登録を解除してもよろしいですか?"
msgstr "<0>{feedName}</0> の登録を解除してもよろしいですか"
#: src/components/header/Header.tsx
msgid "Asc"
@@ -126,7 +126,7 @@ msgstr "使用可能な変数は「title」、「content」、「url」、「aut
#: src/components/content/add/Subscribe.tsx
msgid "Back"
msgstr ""
msgstr "戻る"
#: src/pages/auth/PasswordRecoveryPage.tsx
msgid "Back to log in"
@@ -134,15 +134,15 @@ msgstr "ログインに戻る"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Browser extension required for Chrome"
msgstr ""
msgstr "Chromeのブラウザー拡張が必要です"
#: src/pages/app/AboutPage.tsx
msgid "Browser extention"
msgstr ""
msgstr "ブラウザー拡張"
#: src/components/settings/DisplaySettings.tsx
msgid "Browser tab"
msgstr ""
msgstr "ブラウザータブ"
#: src/components/admin/UserEdit.tsx
#: src/components/content/add/AddCategory.tsx
@@ -170,7 +170,7 @@ msgstr "カテゴリー"
#: src/components/settings/ProfileSettings.tsx
msgid "Changing password will generate a new API key"
msgstr "パスワードを変更すると、新しい API キーが生成されます"
msgstr "パスワードを変更すると、新しいAPIキーが生成されます"
#: src/components/content/add/Subscribe.tsx
msgid "Check that the feed is working"
@@ -178,27 +178,27 @@ msgstr "フィードが動作していることを確認してください"
#: src/pages/app/Layout.tsx
msgid "Close menu"
msgstr ""
msgstr "メニューを閉じる"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Cmd"
msgstr ""
msgstr "Cmd"
#: src/pages/app/AboutPage.tsx
msgid "CommaFeed browser extension version {browserExtensionVersion}."
msgstr ""
msgstr "CommaFeed ブラウザー拡張機能のバージョンは {browserExtensionVersion} です。"
#: src/components/settings/ProfileSettings.tsx
msgid "CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. Login with your username and your <0>API key</0>."
msgstr ""
msgstr "CommaFeedはFever APIと互換性があります。Fever互換のモバイルクライアントで次のURLを使用してください。ユーザー名と <0>API キー</0> でログインしてください。"
#: src/pages/app/AboutPage.tsx
msgid "CommaFeed next unread item"
msgstr "次の未読アイテムをカンマフィード"
msgstr "CommaFeed 次の未読アイテム"
#: src/pages/app/AboutPage.tsx
msgid "CommaFeed version {version} ({revision})."
msgstr ""
msgstr "CommaFeed バージョン {version} ({revision})。"
#: src/components/header/ProfileMenu.tsx
msgid "Compact"
@@ -218,11 +218,11 @@ msgstr "パスワード確認"
#: src/components/header/ProfileMenu.tsx
msgid "Cozy"
msgstr "コージー"
msgstr "Cozy"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Ctrl"
msgstr "コントロール"
msgstr "Ctrl"
#: src/components/settings/ProfileSettings.tsx
msgid "Current password"
@@ -230,19 +230,19 @@ msgstr "現在のパスワード"
#: src/pages/app/SettingsPage.tsx
msgid "Custom code"
msgstr ""
msgstr "カスタムコード"
#: src/components/settings/CustomCodeSettings.tsx
msgid "Custom CSS rules that will be applied"
msgstr ""
msgstr "適用されるカスタムCSSルール"
#: src/components/settings/CustomCodeSettings.tsx
msgid "Custom JS code that will be executed on page load"
msgstr ""
msgstr "ページ読み込み時に実行されるカスタムJSコード"
#: src/components/header/ProfileMenu.tsx
msgid "Dark"
msgstr ""
msgstr "ダーク"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Date created"
@@ -259,7 +259,7 @@ msgstr "アカウント削除"
#: src/pages/app/CategoryDetailsPage.tsx
msgid "Delete Category"
msgstr "カテゴリを削除"
msgstr "カテゴリを削除"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Delete user"
@@ -271,7 +271,7 @@ msgstr "説明"
#: src/components/header/ProfileMenu.tsx
msgid "Detailed"
msgstr ""
msgstr "詳細"
#: src/components/header/ProfileMenu.tsx
#: src/pages/app/SettingsPage.tsx
@@ -281,7 +281,7 @@ msgstr "ディスプレイ"
#: src/components/header/ProfileMenu.tsx
#: src/pages/app/DonatePage.tsx
msgid "Donate"
msgstr ""
msgstr "寄付"
#: src/components/settings/ProfileSettings.tsx
msgid "Download"
@@ -306,7 +306,7 @@ msgstr "メールアドレス"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Edit user"
msgstr "ユーザー編集"
msgstr "ユーザー編集"
#: src/components/admin/UserEdit.tsx
#: src/pages/admin/AdminUsersPage.tsx
@@ -322,16 +322,20 @@ msgid "Enter your current password to change profile settings"
msgstr "プロファイル設定を変更するには、現在のパスワードを入力してください"
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr "エントリーヘッダー"
#: src/components/Alert.tsx
msgid "Error"
msgstr "エラー"
#: src/pages/app/FeedDetailsPage.tsx
msgid "Example: {example}."
msgstr "例: {}."
msgstr "例: {example}."
#: src/components/header/ProfileMenu.tsx
msgid "Expanded"
@@ -344,7 +348,7 @@ msgstr "サブスクリプションとカテゴリを、他のフィード読み
#: src/components/header/Header.tsx
#: src/pages/WelcomePage.tsx
msgid "Extension options"
msgstr ""
msgstr "拡張機能オプション"
#: src/components/content/add/Subscribe.tsx
msgid "Feed name"
@@ -358,15 +362,15 @@ msgstr "フィード URL"
#: src/components/header/ProfileMenu.tsx
msgid "Fetch all my feeds now"
msgstr ""
msgstr "すべてのフィードを今すぐ取得"
#: src/components/settings/ProfileSettings.tsx
msgid "Fever API"
msgstr ""
msgstr "Fever API"
#: src/components/settings/ProfileSettings.tsx
msgid "Fever API URL"
msgstr ""
msgstr "Fever API URL"
#: src/pages/app/FeedDetailsPage.tsx
msgid "Filtering expression"
@@ -374,27 +378,27 @@ msgstr "フィルタリング式"
#: src/pages/auth/LoginPage.tsx
msgid "Forgot password?"
msgstr "パスワードをお忘れですか?"
msgstr "パスワードをお忘れですか"
#: src/pages/app/CategoryDetailsPage.tsx
#: src/pages/app/FeedDetailsPage.tsx
#: src/pages/app/TagDetailsPage.tsx
msgid "Generate an API key in your profile first."
msgstr "最初にプロファイルで API キーを生成します。"
msgstr "最初にプロファイルでAPIキーを生成します。"
#: src/components/settings/ProfileSettings.tsx
msgid "Generate new API key"
msgstr "新しい API キーを生成する"
msgstr "新しいAPIキーを生成する"
#: src/pages/app/CategoryDetailsPage.tsx
#: src/pages/app/FeedDetailsPage.tsx
#: src/pages/app/TagDetailsPage.tsx
msgid "Generated feed url"
msgstr "生成されたフィード URL"
msgstr "生成されたフィードURL"
#: src/components/content/FeedEntryContextMenu.tsx
msgid "Go to {0}"
msgstr ""
msgstr "Go to {0}"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Go to the All view"
@@ -402,11 +406,11 @@ msgstr "すべてのビューに移動"
#: src/pages/app/AboutPage.tsx
msgid "Go to the API documentation."
msgstr "API ドキュメントに移動します。"
msgstr "APIドキュメントに移動します。"
#: src/pages/app/AboutPage.tsx
msgid "Goodies"
msgstr "グッディーズ"
msgstr "グッズ"
#: src/pages/admin/AdminUsersPage.tsx
msgid "Id"
@@ -414,15 +418,15 @@ msgstr "ID"
#: src/pages/app/FeedDetailsPage.tsx
msgid "If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read automatically."
msgstr "空でない場合は、'true' または 'false' に評価される式。 "
msgstr "空でない場合は、'true' または 'false' に評価される式。 'false' の場合、このフィードの新しいエントリは自動的に既読としてマークされます。"
#: src/components/settings/DisplaySettings.tsx
msgid "If the entry doesn't entirely fit on the screen"
msgstr ""
msgstr "エントリが画面に完全に収まらない場合"
#: src/pages/app/AboutPage.tsx
msgid "If you encounter an issue, please report it on the issues page of the GitHub project."
msgstr "問題が発生した場合は、GitHub プロジェクトの問題ページで報告してください。"
msgstr "問題が発生した場合は、GitHubプロジェクトのissuesページで報告してください。"
#: src/components/content/add/ImportOpml.tsx
msgid "Import"
@@ -430,7 +434,7 @@ msgstr "インポート"
#: src/components/settings/DisplaySettings.tsx
msgid "In expanded view, scrolling through entries mark them as read"
msgstr "展開ビューでエントリをスクロールすると、それらが既読としてマークされます"
msgstr "展開ビューでエントリをスクロールすると、それらが既読としてマークされます"
#: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx
@@ -460,7 +464,7 @@ msgstr "最終更新メッセージ"
#: src/components/header/ProfileMenu.tsx
msgid "Light"
msgstr ""
msgstr "ライト"
#: src/pages/app/CategoryDetailsPage.tsx
#: src/pages/app/FeedDetailsPage.tsx
@@ -496,7 +500,7 @@ msgstr "ログアウト"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Long press"
msgstr ""
msgstr "長押し"
#: src/components/header/ProfileMenu.tsx
#: src/pages/admin/AdminUsersPage.tsx
@@ -510,7 +514,7 @@ msgstr "すべて既読にする"
#: src/components/header/MarkAllAsReadButton.tsx
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Mark all entries as read"
msgstr "すべてのエントリを既読にする"
msgstr "すべてのエントリを既読にする"
#: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx
@@ -524,11 +528,11 @@ msgstr "ここまで既読にする"
#: src/components/header/ProfileMenu.tsx
msgid "Metrics"
msgstr "メトリクス"
msgstr "メトリクス"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Middle click"
msgstr ""
msgstr "中クリック"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Move the page down"
@@ -557,7 +561,7 @@ msgstr "名前を入力してサブスクリプションに移動します"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx
msgid "Never"
msgstr ""
msgstr "しない"
#: src/components/settings/ProfileSettings.tsx
msgid "New password"
@@ -582,11 +586,11 @@ msgstr "次の未読アイテムのブックマークレット"
#: src/pages/app/FeedEntriesPage.tsx
msgid "No more entries"
msgstr "これ以上エントリはありません"
msgstr "これ以上エントリはありません"
#: src/components/content/ShareButtons.tsx
msgid "No sharing options available."
msgstr ""
msgstr "共有オプションは利用できません。"
#: src/components/sidebar/TreeSearch.tsx
msgid "Nothing found"
@@ -598,14 +602,18 @@ msgstr "古い順"
#: src/components/settings/DisplaySettings.tsx
msgid "On desktop"
msgstr ""
msgstr "デスクトップ"
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile"
msgstr ""
msgstr "モバイル"
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "モバイルでは、画面の下部にアクションボタンを表示します"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
@@ -614,15 +622,15 @@ msgstr "おっと!"
#: src/components/header/Header.tsx
msgid "Open CommaFeed"
msgstr ""
msgstr "CommaFeedを開く"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Open current entry in a new tab"
msgstr "現在のエントリを新しいタブで開く"
msgstr "現在のエントリを新しいタブで開く"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Open current entry in a new tab in the background"
msgstr "現在のエントリバックグラウンドで新しいタブで開く"
msgstr "現在のエントリーを新しいバックグラウンドタブで開く"
#: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/OpenExternalLink.tsx
@@ -631,31 +639,31 @@ msgstr "リンクを開く"
#: src/components/content/FeedEntryContextMenu.tsx
msgid "Open link in new background tab"
msgstr ""
msgstr "リンクを新しいバックグラウンドタブで開く"
#: src/components/content/FeedEntryContextMenu.tsx
msgid "Open link in new tab"
msgstr ""
msgstr "リンクを新しいタブで開く"
#: src/pages/app/Layout.tsx
msgid "Open menu"
msgstr ""
msgstr "メニューを開く"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Open next entry"
msgstr "次のエントリを開く"
msgstr "次のエントリを開く"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Open previous entry"
msgstr "前のエントリを開く"
msgstr "前のエントリを開く"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Open/close current entry"
msgstr "現在のエントリを開く/閉じる"
msgstr "現在のエントリを開く/閉じる"
#: src/pages/app/AddPage.tsx
msgid "OPML"
msgstr ""
msgstr "OPML"
#: src/components/settings/ProfileSettings.tsx
msgid "OPML export"
@@ -668,7 +676,7 @@ msgstr "OPMLファイル"
#: src/components/content/add/ImportOpml.tsx
msgid "OPML file is required"
msgstr ""
msgstr "OPMLファイルは必要です"
#: src/pages/app/AboutPage.tsx
msgid "Order"
@@ -705,7 +713,7 @@ msgstr "位置"
#: src/components/header/Header.tsx
msgid "Previous"
msgstr ""
msgstr "前へ"
#: src/pages/app/SettingsPage.tsx
msgid "Profile"
@@ -722,16 +730,16 @@ msgstr "リフレッシュ"
#: src/pages/auth/RegistrationPage.tsx
msgid "Registrations are closed on this CommaFeed instance"
msgstr "この CommaFeed インスタンスの登録は終了しています"
msgstr "このCommaFeedインスタンスの登録は終了しています"
#: src/pages/app/AboutPage.tsx
msgid "REST API"
msgstr ""
msgstr "REST API"
#: src/components/KeyboardShortcutsHelp.tsx
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Right click"
msgstr ""
msgstr "右クリック"
#: src/components/admin/UserEdit.tsx
#: src/components/settings/CustomCodeSettings.tsx
@@ -743,15 +751,15 @@ msgstr "保存"
#: src/components/settings/DisplaySettings.tsx
msgid "Scroll selected entry to the top of the page"
msgstr ""
msgstr "選択されたエントリーをページの上部にスクロールする"
#: src/components/settings/DisplaySettings.tsx
msgid "Scroll smoothly when navigating between entries"
msgstr "エントリ間を移動するときにスムーズにスクロールする"
msgstr "エントリ間を移動するときにスムーズにスクロールする"
#: src/components/settings/DisplaySettings.tsx
msgid "Scrolling"
msgstr ""
msgstr "スクロール"
#: src/components/header/Header.tsx
#: src/components/header/Header.tsx
@@ -762,15 +770,15 @@ msgstr "検索"
#: src/components/header/Header.tsx
msgid "Search requires at least 3 characters"
msgstr "検索には少なくとも 3 文字が必要です"
msgstr "検索には少なくとも3文字が必要です"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Set focus on next entry without opening it"
msgstr "次のエントリを開かずにフォーカスを設定する"
msgstr "次のエントリを開かずにフォーカスを設定する"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Set focus on previous entry without opening it"
msgstr "前のエントリを開かずにフォーカスを設定する"
msgstr "前のエントリを開かずにフォーカスを設定する"
#: src/components/header/ProfileMenu.tsx
msgid "Settings"
@@ -792,31 +800,31 @@ msgstr "共有サイト"
#: src/components/KeyboardShortcutsHelp.tsx
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Shift"
msgstr "シフト"
msgstr "Shift"
#: src/components/settings/DisplaySettings.tsx
msgid "Show CommaFeed's own context menu on right click"
msgstr ""
msgstr "右クリックでCommaFeedのコンテキストメニューを表示する"
#: src/components/settings/DisplaySettings.tsx
msgid "Show confirmation when marking all entries as read"
msgstr ""
msgstr "すべてのエントリーを既読にするときに確認を表示する"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Show entry menu (desktop)"
msgstr ""
msgstr "エントリーメニューを表示する(デスクトップ)"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Show entry menu (mobile)"
msgstr ""
msgstr "エントリーメニューを表示する(モバイル)"
#: src/components/settings/DisplaySettings.tsx
msgid "Show external link icon"
msgstr ""
msgstr "外部リンクアイコンを表示する"
#: src/components/settings/DisplaySettings.tsx
msgid "Show feeds and categories with no unread entries"
msgstr "未読エントリのないフィードとカテゴリを表示する"
msgstr "未読エントリのないフィードとカテゴリを表示する"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Show keyboard shortcut help"
@@ -824,19 +832,19 @@ msgstr "キーボード ショートカットのヘルプを表示"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Show native menu (desktop)"
msgstr ""
msgstr "ネイティブメニューを表示する(デスクトップ)"
#: src/components/settings/DisplaySettings.tsx
msgid "Show star icon"
msgstr ""
msgstr "スターアイコンを表示する"
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab favicon"
msgstr ""
msgstr "未読数をタブのアイコンに表示する"
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab title"
msgstr ""
msgstr "未読数をタブのタイトルに表示する"
#: src/pages/auth/RegistrationPage.tsx
#: src/pages/auth/RegistrationPage.tsx
@@ -846,12 +854,12 @@ msgstr "サインアップ"
#: src/pages/ErrorPage.tsx
msgid "Something bad just happened..."
msgstr "何か悪いことが起こった..."
msgstr "何か悪いことが起きました..."
#: src/components/KeyboardShortcutsHelp.tsx
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Space"
msgstr "スペース"
msgstr "Space"
#: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx
@@ -884,7 +892,7 @@ msgstr "成功"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Swipe header to the left"
msgstr ""
msgstr "ヘッダーを左にスワイプ"
#: src/pages/WelcomePage.tsx
msgid "Switch to dark theme"
@@ -896,7 +904,7 @@ msgstr "ライトテーマに切り替え"
#: src/components/header/ProfileMenu.tsx
msgid "System"
msgstr ""
msgstr "システム"
#: src/components/content/FeedEntryFooter.tsx
#: src/components/content/FeedEntryFooter.tsx
@@ -905,7 +913,7 @@ msgstr "タグ"
#: src/components/content/add/Subscribe.tsx
msgid "The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed will try to find the feed in the page."
msgstr "購読したいフィードのURL。 "
msgstr "購読したいフィードのURL。ウェブサイトのURLを直接使用して、CommaFeedはページ内のフィードを検索します。"
#: src/components/header/ProfileMenu.tsx
msgid "Theme"
@@ -913,27 +921,27 @@ msgstr "テーマ"
#: src/components/settings/ProfileSettings.tsx
msgid "This is your API key. It can be used for some read-only API operations and grants access to the Fever API. Use the form at the bottom of the page to generate a new API key"
msgstr ""
msgstr "これはあなたのAPIキーです。いくつかの読み取り専用API操作に使用できます。これにより、Fever APIへのアクセスが可能になります。ページの下部のフォームを使用して新しいAPIキーを生成します。"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Toggle read status of current entry"
msgstr "現在のエントリの読み取りステータスを切り替えます"
msgstr "現在のエントリの読み取りステータスを切り替えます"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Toggle sidebar"
msgstr ""
msgstr "サイドバーを切り替える"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Toggle starred status of current entry"
msgstr ""
msgstr "現在のエントリーのスターステータスを切り替える"
#: src/pages/auth/LoginPage.tsx
msgid "Try out CommaFeed with the demo account: demo/demo"
msgstr "デモアカウントで CommaFeed を試す: demo/demo"
msgstr "デモアカウントでCommaFeedを試す: demo/demo"
#: src/pages/WelcomePage.tsx
msgid "Try the demo!"
msgstr ""
msgstr "デモを試す!"
#: src/components/header/Header.tsx
msgid "Unread"
@@ -969,8 +977,8 @@ msgstr "ウェブサイト"
#: src/pages/app/FeedEntriesPage.tsx
msgid "You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?"
msgstr "まだサブスクリプションがありません。"
msgstr "まだサブスクリプションがありません。上部の + 記号をクリックして1つ追加してみませんか"
#: src/components/header/ProfileMenu.tsx
msgid "Your feeds have been queued for refresh."
msgstr ""
msgstr "フィードの更新がキューに登録されました。"

View File

@@ -321,6 +321,10 @@ msgstr "입력"
msgid "Enter your current password to change profile settings"
msgstr "프로필 설정을 변경하려면 현재 비밀번호를 입력하세요."
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "앗!"

View File

@@ -321,6 +321,10 @@ msgstr "Masuk"
msgid "Enter your current password to change profile settings"
msgstr "Masukkan kata laluan semasa anda untuk menukar tetapan profil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Aduh!"

View File

@@ -321,6 +321,10 @@ msgstr ""
msgid "Enter your current password to change profile settings"
msgstr "Skriv inn ditt nåværende passord for å endre profilinnstillinger"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Beklager!"

View File

@@ -321,6 +321,10 @@ msgstr ""
msgid "Enter your current password to change profile settings"
msgstr "Voer uw huidige wachtwoord in om de profielinstellingen te wijzigen"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Oeps!"

View File

@@ -321,6 +321,10 @@ msgstr ""
msgid "Enter your current password to change profile settings"
msgstr "Skriv inn ditt nåværende passord for å endre profilinnstillinger"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Beklager!"

View File

@@ -321,6 +321,10 @@ msgstr "Wprowadź"
msgid "Enter your current password to change profile settings"
msgstr "Wprowadź swoje aktualne hasło, aby zmienić ustawienia profilu"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Ups!"

View File

@@ -321,6 +321,10 @@ msgstr "Entrar"
msgid "Enter your current password to change profile settings"
msgstr "Digite sua senha atual para alterar as configurações do perfil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Opa!"

View File

@@ -321,6 +321,10 @@ msgstr "Ввод"
msgid "Enter your current password to change profile settings"
msgstr "Введите текущий пароль, чтобы изменить настройки профиля."
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "На мобильных устройствах отображать кнопки действий в нижней части экрана"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Ой!"

View File

@@ -321,6 +321,10 @@ msgstr "Vstúpte"
msgid "Enter your current password to change profile settings"
msgstr "Ak chcete zmeniť nastavenia profilu, zadajte svoje aktuálne heslo"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Ojoj!"

View File

@@ -321,6 +321,10 @@ msgstr ""
msgid "Enter your current password to change profile settings"
msgstr "Ange ditt nuvarande lösenord för att ändra profilinställningar"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Hoppsan!"

View File

@@ -321,6 +321,10 @@ msgstr "Girin"
msgid "Enter your current password to change profile settings"
msgstr "Profil ayarlarını değiştirmek için mevcut şifrenizi girin"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
@@ -608,6 +612,10 @@ msgstr ""
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "Hata!"

View File

@@ -321,6 +321,10 @@ msgstr "回车"
msgid "Enter your current password to change profile settings"
msgstr "输入您当前的密码以更改配置文件设置"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr "条目头部"
@@ -608,6 +612,10 @@ msgstr "移动端"
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "在移动端,显示屏幕底部的操作按钮"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx
msgid "Oops!"
msgstr "哎呀!"

View File

@@ -23,6 +23,8 @@ const shownGauges: Record<string, string> = {
"com.commafeed.backend.HttpGetter.pool.size": "HttpGetter current pool size",
"com.commafeed.backend.HttpGetter.pool.leased": "HttpGetter active connections",
"com.commafeed.backend.HttpGetter.pool.pending": "HttpGetter waiting for a connection",
"com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries",
"com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage",
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
"com.commafeed.frontend.ws.WebSocketSessions.sessions": "WebSocket sessions",
}

View File

@@ -5,6 +5,7 @@ import { redirectToAdd, redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { setMobileMenuOpen } from "app/tree/slice"
import { reloadTree } from "app/tree/thunks"
import { setSidebarWidth } from "app/user/slice"
import { reloadProfile, reloadSettings, reloadTags } from "app/user/thunks"
import { ActionButton } from "components/ActionButton"
import { AnnouncementDialog } from "components/AnnouncementDialog"
@@ -17,13 +18,12 @@ import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile"
import { useWebSocket } from "hooks/useWebSocket"
import { LoadingPage } from "pages/LoadingPage"
import { type ReactNode, Suspense, useEffect } from "react"
import { type ReactNode, Suspense, useEffect, useRef } from "react"
import Draggable from "react-draggable"
import { TbMenu2, TbPlus, TbX } from "react-icons/tb"
import { Outlet } from "react-router-dom"
import { useSwipeable } from "react-swipeable"
import { tss } from "tss"
import useLocalStorage from "use-local-storage"
interface LayoutProps {
sidebar: ReactNode
@@ -64,21 +64,24 @@ export default function Layout(props: LayoutProps) {
const theme = useMantineTheme()
const mobile = useMobile()
const { isBrowserExtensionPopup } = useBrowserExtension()
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar-width", 350)
const draggableSeparator = useRef<HTMLDivElement>(null)
const { loading } = useAppLoading()
const mobileMenuOpen = useAppSelector(state => state.tree.mobileMenuOpen)
const webSocketConnected = useAppSelector(state => state.server.webSocketConnected)
const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval)
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
const sidebarWidth = useAppSelector(state => state.user.localSettings.sidebarWidth)
const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter
const dispatch = useAppDispatch()
useWebSocket()
const sidebarPadding = theme.spacing.xs
const { classes } = useStyles({
sidebarWidth,
sidebarPadding,
sidebarRightBorderWidth: "1px",
})
const { loading } = useAppLoading()
const mobileMenuOpen = useAppSelector(state => state.tree.mobileMenuOpen)
const webSocketConnected = useAppSelector(state => state.server.webSocketConnected)
const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval)
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter
const dispatch = useAppDispatch()
useWebSocket()
useEffect(() => {
// load initial data
@@ -182,6 +185,7 @@ export default function Layout(props: LayoutProps) {
</AppShell.Navbar>
<OnDesktop>
<Draggable
nodeRef={draggableSeparator}
axis="x"
defaultPosition={{
x: sidebarWidth,
@@ -192,9 +196,13 @@ export default function Layout(props: LayoutProps) {
right: 1000,
}}
grid={[30, 30]}
onDrag={(_e, data) => setSidebarWidth(data.x)}
onDrag={(_e, data) => {
dispatch(setSidebarWidth(data.x))
return
}}
>
<Box
ref={draggableSeparator}
style={{
position: "fixed",
height: "100%",

View File

@@ -2,11 +2,11 @@ import { lingui } from "@lingui/vite-plugin"
import react from "@vitejs/plugin-react"
import { visualizer } from "rollup-plugin-visualizer"
import { defineConfig } from "vite"
import biomePlugin from "vite-plugin-biome"
import checker from "vite-plugin-checker"
import tsconfigPaths from "vite-tsconfig-paths"
// https://vitejs.dev/config/
export default defineConfig(env => ({
export default defineConfig(() => ({
plugins: [
react({
babel: {
@@ -17,9 +17,11 @@ export default defineConfig(env => ({
lingui(),
tsconfigPaths(),
visualizer(),
biomePlugin({
mode: "check",
failOnError: env.mode !== "development",
checker({
typescript: true,
biome: {
command: "check",
},
}),
],
base: "./",
@@ -49,4 +51,7 @@ export default defineConfig(env => ({
},
},
},
test: {
environment: "jsdom",
},
}))

View File

@@ -145,7 +145,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_CONNECT_TIMEOUT+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`5S`
a| [[commafeed-server_commafeed-http-client-ssl-handshake-timeout]] [.property-path]##`commafeed.http-client.ssl-handshake-timeout`##
@@ -162,7 +162,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_SSL_HANDSHAKE_TIMEOUT+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`5S`
a| [[commafeed-server_commafeed-http-client-socket-timeout]] [.property-path]##`commafeed.http-client.socket-timeout`##
@@ -179,7 +179,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_SOCKET_TIMEOUT+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`10S`
a| [[commafeed-server_commafeed-http-client-response-timeout]] [.property-path]##`commafeed.http-client.response-timeout`##
@@ -196,7 +196,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_RESPONSE_TIMEOUT+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`10S`
a| [[commafeed-server_commafeed-http-client-connection-time-to-live]] [.property-path]##`commafeed.http-client.connection-time-to-live`##
@@ -213,7 +213,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_CONNECTION_TIME_TO_LIVE+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`30S`
a| [[commafeed-server_commafeed-http-client-idle-connections-eviction-interval]] [.property-path]##`commafeed.http-client.idle-connections-eviction-interval`##
@@ -230,7 +230,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_IDLE_CONNECTIONS_EVICTION_INTERVAL+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`1M`
a| [[commafeed-server_commafeed-http-client-max-response-size]] [.property-path]##`commafeed.http-client.max-response-size`##
@@ -250,6 +250,62 @@ endif::add-copy-button-to-env-var[]
|MemorySize link:#memory-size-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the MemorySize format]]
|`5M`
h|[[commafeed-server_section_commafeed-http-client-cache]] [.section-name.section-level1]##HTTP client cache configuration##
h|Type
h|Default
a| [[commafeed-server_commafeed-http-client-cache-enabled]] [.property-path]##`commafeed.http-client.cache.enabled`##
[.description]
--
Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts (e.g. when subscribing to a feed for the first time or when clicking "fetch all my feeds now").
ifdef::add-copy-button-to-env-var[]
Environment variable: env_var_with_copy_button:+++COMMAFEED_HTTP_CLIENT_CACHE_ENABLED+++[]
endif::add-copy-button-to-env-var[]
ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_CACHE_ENABLED+++`
endif::add-copy-button-to-env-var[]
--
|boolean
|`true`
a| [[commafeed-server_commafeed-http-client-cache-maximum-memory-size]] [.property-path]##`commafeed.http-client.cache.maximum-memory-size`##
[.description]
--
Maximum amount of memory the cache can use.
ifdef::add-copy-button-to-env-var[]
Environment variable: env_var_with_copy_button:+++COMMAFEED_HTTP_CLIENT_CACHE_MAXIMUM_MEMORY_SIZE+++[]
endif::add-copy-button-to-env-var[]
ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_CACHE_MAXIMUM_MEMORY_SIZE+++`
endif::add-copy-button-to-env-var[]
--
|MemorySize link:#memory-size-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the MemorySize format]]
|`10M`
a| [[commafeed-server_commafeed-http-client-cache-expiration]] [.property-path]##`commafeed.http-client.cache.expiration`##
[.description]
--
Duration after which an entry is removed from the cache.
ifdef::add-copy-button-to-env-var[]
Environment variable: env_var_with_copy_button:+++COMMAFEED_HTTP_CLIENT_CACHE_EXPIRATION+++[]
endif::add-copy-button-to-env-var[]
ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_HTTP_CLIENT_CACHE_EXPIRATION+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`1M`
h|[[commafeed-server_section_commafeed-feed-refresh]] [.section-name.section-level0]##Feed refresh engine settings##
h|Type
@@ -269,7 +325,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_FEED_REFRESH_INTERVAL+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`5M`
a| [[commafeed-server_commafeed-feed-refresh-interval-empirical]] [.property-path]##`commafeed.feed-refresh.interval-empirical`##
@@ -303,7 +359,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_FEED_REFRESH_HTTP_THREADS+++`
endif::add-copy-button-to-env-var[]
--
|@jakarta.validation.constraints.Min(1L) int
|int
|`3`
a| [[commafeed-server_commafeed-feed-refresh-database-threads]] [.property-path]##`commafeed.feed-refresh.database-threads`##
@@ -320,7 +376,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_FEED_REFRESH_DATABASE_THREADS+++`
endif::add-copy-button-to-env-var[]
--
|@jakarta.validation.constraints.Min(1L) int
|int
|`1`
a| [[commafeed-server_commafeed-feed-refresh-user-inactivity-period]] [.property-path]##`commafeed.feed-refresh.user-inactivity-period`##
@@ -337,7 +393,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_FEED_REFRESH_USER_INACTIVITY_PERIOD+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`0S`
a| [[commafeed-server_commafeed-feed-refresh-filtering-expression-evaluation-timeout]] [.property-path]##`commafeed.feed-refresh.filtering-expression-evaluation-timeout`##
@@ -354,7 +410,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_FEED_REFRESH_FILTERING_EXPRESSION_EVALUATION_TIMEOUT+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`500MS`
@@ -376,7 +432,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_DATABASE_QUERY_TIMEOUT+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`0S`
h|[[commafeed-server_section_commafeed-database-cleanup]] [.section-name.section-level1]##Database cleanup settings##
@@ -397,7 +453,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_DATABASE_CLEANUP_ENTRIES_MAX_AGE+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`365D`
a| [[commafeed-server_commafeed-database-cleanup-statuses-max-age]] [.property-path]##`commafeed.database.cleanup.statuses-max-age`##
@@ -414,7 +470,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_DATABASE_CLEANUP_STATUSES_MAX_AGE+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`0S`
a| [[commafeed-server_commafeed-database-cleanup-max-feed-capacity]] [.property-path]##`commafeed.database.cleanup.max-feed-capacity`##
@@ -465,7 +521,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_DATABASE_CLEANUP_BATCH_SIZE+++`
endif::add-copy-button-to-env-var[]
--
|@jakarta.validation.constraints.Positive int
|int
|`100`
@@ -561,7 +617,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_WEBSOCKET_PING_INTERVAL+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`15M`
a| [[commafeed-server_commafeed-websocket-tree-reload-interval]] [.property-path]##`commafeed.websocket.tree-reload-interval`##
@@ -578,7 +634,7 @@ ifndef::add-copy-button-to-env-var[]
Environment variable: `+++COMMAFEED_WEBSOCKET_TREE_RELOAD_INTERVAL+++`
endif::add-copy-button-to-env-var[]
--
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-{summaryTableId}[icon:question-circle[title=More information about the Duration format]]
|`30S`

View File

@@ -1,19 +0,0 @@
version: "3.1"
services:
mysql:
image: mariadb
environment:
- MYSQL_ROOT_PASSWORD=commafeed
- MYSQL_DATABASE=commafeed
ports:
- "3306:3306"
postgresql:
image: postgres
environment:
- POSTGRES_USER=commafeed
- POSTGRES_PASSWORD=commafeed
- POSTGRES_DB=commafeed
ports:
- "5432:5432"

View File

@@ -6,17 +6,16 @@
<parent>
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.1.0</version>
<version>5.2.0</version>
</parent>
<artifactId>commafeed-server</artifactId>
<name>CommaFeed Server</name>
<properties>
<quarkus.version>3.14.1</quarkus.version>
<quarkus.version>3.14.4</quarkus.version>
<querydsl.version>6.7</querydsl.version>
<rome.version>2.1.0</rome.version>
<swagger.version>2.2.23</swagger.version>
<properties-plugin.version>1.2.1</properties-plugin.version>
<build.database>h2</build.database>
</properties>
@@ -54,7 +53,26 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>properties-maven-plugin</artifactId>
<version>1.2.1</version>
<executions>
<execution>
<goals>
<goal>set-system-properties</goal>
</goals>
</execution>
</executions>
<configuration>
<properties>
<property>
<name>quarkus.datasource.db-kind</name>
<value>${build.database}</value>
</property>
</properties>
</configuration>
</plugin>
<plugin>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
@@ -121,6 +139,7 @@
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<quarkus.datasource.db-kind>${build.database}</quarkus.datasource.db-kind>
</systemPropertyVariables>
</configuration>
</plugin>
@@ -141,8 +160,32 @@
<native.image.path>${project.build.directory}/${project.build.finalName}-runner
</native.image.path>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<quarkus.datasource.db-kind>${build.database}</quarkus.datasource.db-kind>
</systemPropertyVariables>
</configuration>
<!-- failsafe plugin does not seem to be able to pick up dependencies declared in profiles -->
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
<version>${quarkus.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mysql</artifactId>
<version>${quarkus.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mariadb</artifactId>
<version>${quarkus.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
<version>${quarkus.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>io.github.git-commit-id</groupId>
@@ -251,9 +294,10 @@
<dependency>
<groupId>com.commafeed</groupId>
<artifactId>commafeed-client</artifactId>
<version>5.1.0</version>
<version>5.2.0</version>
</dependency>
<!-- compile-time processors -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -266,7 +310,14 @@
<version>1.11</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-extension-processor</artifactId>
<version>${quarkus.version}</version>
<scope>provided</scope>
</dependency>
<!-- quarkus dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
@@ -303,40 +354,17 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-liquibase</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mysql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mariadb</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-extension-processor</artifactId>
<version>${quarkus.version}</version>
</dependency>
<dependency>
<groupId>io.dropwizard.metrics</groupId>
<artifactId>metrics-json</artifactId>
<version>4.2.27</version>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<version>${swagger.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
@@ -349,7 +377,6 @@
<artifactId>querydsl-jpa</artifactId>
<version>${querydsl.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
@@ -380,7 +407,6 @@
<artifactId>passay</artifactId>
<version>1.6.5</version>
</dependency>
<dependency>
<groupId>com.rometools</groupId>
<artifactId>rome</artifactId>
@@ -396,7 +422,6 @@
<artifactId>rome-opml</artifactId>
<version>${rome.version}</version>
</dependency>
<dependency>
<groupId>org.ahocorasick</groupId>
<artifactId>ahocorasick</artifactId>
@@ -422,11 +447,6 @@
<artifactId>urlcanon</artifactId>
<version>0.4.0</version>
</dependency>
<dependency>
<groupId>org.gwtproject</groupId>
<artifactId>gwt-servlet</artifactId>
<version>2.11.0</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
@@ -438,26 +458,16 @@
<artifactId>dec</artifactId>
<version>0.1.2</version>
</dependency>
<dependency>
<groupId>io.github.hakky54</groupId>
<artifactId>sslcontext-kickstart-for-apache5</artifactId>
<version>8.3.6</version>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<artifactId>quarkus-junit5-mockito</artifactId>
<scope>test</scope>
</dependency>
<dependency>
@@ -479,7 +489,7 @@
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.46.0</version>
<version>1.47.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -488,7 +498,6 @@
<version>0.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
@@ -512,120 +521,48 @@
<properties>
<build.database>h2</build.database>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>properties-maven-plugin</artifactId>
<version>${properties-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>set-system-properties</goal>
</goals>
</execution>
</executions>
<configuration>
<properties>
<property>
<name>quarkus.datasource.db-kind</name>
<value>h2</value>
</property>
</properties>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
</dependencies>
</profile>
<profile>
<id>mysql</id>
<properties>
<build.database>mysql</build.database>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>properties-maven-plugin</artifactId>
<version>${properties-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>set-system-properties</goal>
</goals>
</execution>
</executions>
<configuration>
<properties>
<property>
<name>quarkus.datasource.db-kind</name>
<value>mysql</value>
</property>
</properties>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mysql</artifactId>
</dependency>
</dependencies>
</profile>
<profile>
<id>mariadb</id>
<properties>
<build.database>mariadb</build.database>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>properties-maven-plugin</artifactId>
<version>${properties-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>set-system-properties</goal>
</goals>
</execution>
</executions>
<configuration>
<properties>
<property>
<name>quarkus.datasource.db-kind</name>
<value>mariadb</value>
</property>
</properties>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-mariadb</artifactId>
</dependency>
</dependencies>
</profile>
<profile>
<id>postgresql</id>
<properties>
<build.database>postgresql</build.database>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>properties-maven-plugin</artifactId>
<version>${properties-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>set-system-properties</goal>
</goals>
</execution>
</executions>
<configuration>
<properties>
<property>
<name>quarkus.datasource.db-kind</name>
<value>postgresql</value>
</property>
</properties>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
</dependencies>
</profile>
</profiles>
</project>

View File

@@ -1,4 +1,4 @@
FROM debian:12.6
FROM debian:12.7
EXPOSE 8082
RUN mkdir -p /commafeed/data

View File

@@ -79,6 +79,10 @@ meaning that you will have to log back in after each restart of the application.
`QUARKUS_HTTP_AUTH_SESSION_ENCRYPTION_KEY` variable to a fixed value (min. 16 characters).
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
### Updates
When CommaFeed is up and running, you can subscribe to [this feed](https://github.com/Athou/commafeed/releases.atom) to be notified of new releases.
## Docker tags
Tags are of the form `<version>-<database>[-jvm]` where:

View File

@@ -137,6 +137,33 @@ public interface CommaFeedConfiguration {
*/
@WithDefault("5M")
MemorySize maxResponseSize();
/**
* HTTP client cache configuration
*/
@ConfigDocSection
HttpClientCache cache();
}
interface HttpClientCache {
/**
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts (e.g. when subscribing to a feed for the
* first time or when clicking "fetch all my feeds now").
*/
@WithDefault("true")
boolean enabled();
/**
* Maximum amount of memory the cache can use.
*/
@WithDefault("10M")
MemorySize maximumMemorySize();
/**
* Duration after which an entry is removed from the cache.
*/
@WithDefault("1m")
Duration expiration();
}
interface FeedRefresh {

View File

@@ -7,6 +7,7 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.config.ConnectionConfig;
@@ -31,14 +32,19 @@ import org.apache.hc.core5.util.Timeout;
import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConfiguration.HttpClientCache;
import com.commafeed.CommaFeedVersion;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Iterables;
import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders;
import jakarta.inject.Singleton;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import nl.altindag.ssl.SSLFactory;
import nl.altindag.ssl.apache5.util.Apache5SslUtils;
@@ -52,6 +58,7 @@ public class HttpGetter {
private final CommaFeedConfiguration config;
private final CloseableHttpClient client;
private final Cache<HttpRequest, HttpResponse> cache;
public HttpGetter(CommaFeedConfiguration config, CommaFeedVersion version, MetricRegistry metrics) {
this.config = config;
@@ -62,42 +69,66 @@ public class HttpGetter {
.orElseGet(() -> String.format("CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion()));
this.client = newClient(connectionManager, userAgent, config.httpClient().idleConnectionsEvictionInterval());
this.cache = newCache(config);
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "max"), () -> connectionManager.getTotalStats().getMax());
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "size"),
() -> connectionManager.getTotalStats().getAvailable() + connectionManager.getTotalStats().getLeased());
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "leased"), () -> connectionManager.getTotalStats().getLeased());
metrics.registerGauge(MetricRegistry.name(getClass(), "pool", "pending"), () -> connectionManager.getTotalStats().getPending());
metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size());
metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "memoryUsage"),
() -> cache == null ? 0 : cache.asMap().values().stream().mapToInt(e -> e.content != null ? e.content.length : 0).sum());
}
public HttpResult getBinary(String url) throws IOException, NotModifiedException {
return getBinary(url, null, null);
public HttpResult get(String url) throws IOException, NotModifiedException {
return get(HttpRequest.builder(url).build());
}
/**
* @param url
* the url to retrive
* @param lastModified
* header we got last time we queried that url, or null
* @param eTag
* header we got last time we queried that url, or null
* @throws NotModifiedException
* if the url hasn't changed since we asked for it last time
*/
public HttpResult getBinary(String url, String lastModified, String eTag) throws IOException, NotModifiedException {
log.debug("fetching {}", url);
public HttpResult get(HttpRequest request) throws IOException, NotModifiedException {
final HttpResponse response;
if (cache == null) {
response = invoke(request);
} else {
try {
response = cache.get(request, () -> invoke(request));
} catch (ExecutionException e) {
if (e.getCause() instanceof IOException ioe) {
throw ioe;
} else {
throw new RuntimeException(e);
}
}
}
ClassicHttpRequest request = ClassicRequestBuilder.get(url).build();
if (lastModified != null) {
request.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
int code = response.getCode();
if (code == HttpStatus.SC_NOT_MODIFIED) {
throw new NotModifiedException("'304 - not modified' http code received");
} else if (code >= 300) {
throw new HttpResponseException(code, "Server returned HTTP error code " + code);
}
if (eTag != null) {
request.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
String lastModifiedHeader = response.getLastModifiedHeader();
if (lastModifiedHeader != null && lastModifiedHeader.equals(request.getLastModified())) {
throw new NotModifiedException("lastModifiedHeader is the same");
}
String eTagHeader = response.getETagHeader();
if (eTagHeader != null && eTagHeader.equals(request.getETag())) {
throw new NotModifiedException("eTagHeader is the same");
}
return new HttpResult(response.getContent(), response.getContentType(), lastModifiedHeader, eTagHeader,
response.getUrlAfterRedirect());
}
private HttpResponse invoke(HttpRequest request) throws IOException {
log.debug("fetching {}", request.getUrl());
HttpClientContext context = HttpClientContext.create();
context.setRequestConfig(RequestConfig.custom().setResponseTimeout(Timeout.of(config.httpClient().responseTimeout())).build());
HttpResponse response = client.execute(request, context, resp -> {
return client.execute(request.toClassicHttpRequest(), context, resp -> {
byte[] content = resp.getEntity() == null ? null
: toByteArray(resp.getEntity(), config.httpClient().maxResponseSize().asLongValue());
int code = resp.getCode();
@@ -115,30 +146,10 @@ public class HttpGetter {
.map(RedirectLocations::getAll)
.map(l -> Iterables.getLast(l, null))
.map(URI::toString)
.orElse(url);
.orElse(request.getUrl());
return new HttpResponse(code, lastModifiedHeader, eTagHeader, content, contentType, urlAfterRedirect);
});
int code = response.getCode();
if (code == HttpStatus.SC_NOT_MODIFIED) {
throw new NotModifiedException("'304 - not modified' http code received");
} else if (code >= 300) {
throw new HttpResponseException(code, "Server returned HTTP error code " + code);
}
String lastModifiedHeader = response.getLastModifiedHeader();
if (lastModifiedHeader != null && lastModifiedHeader.equals(lastModified)) {
throw new NotModifiedException("lastModifiedHeader is the same");
}
String eTagHeader = response.getETagHeader();
if (eTagHeader != null && eTagHeader.equals(eTag)) {
throw new NotModifiedException("eTagHeader is the same");
}
return new HttpResult(response.getContent(), response.getContentType(), lastModifiedHeader, eTagHeader,
response.getUrlAfterRedirect());
}
private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException {
@@ -197,6 +208,19 @@ public class HttpGetter {
.build();
}
private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
HttpClientCache cacheConfig = config.httpClient().cache();
if (!cacheConfig.enabled()) {
return null;
}
return CacheBuilder.newBuilder()
.weigher((HttpRequest key, HttpResponse value) -> value.getContent() != null ? value.getContent().length : 0)
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
.expireAfterWrite(cacheConfig.expiration())
.build();
}
@Getter
public static class NotModifiedException extends Exception {
private static final long serialVersionUID = 1L;
@@ -232,28 +256,49 @@ public class HttpGetter {
super(message);
this.code = code;
}
}
@Builder(builderMethodName = "")
@EqualsAndHashCode
@Getter
@RequiredArgsConstructor
public static class HttpRequest {
private String url;
private String lastModified;
private String eTag;
public static HttpRequestBuilder builder(String url) {
return new HttpRequestBuilder().url(url);
}
public ClassicHttpRequest toClassicHttpRequest() {
ClassicHttpRequest req = ClassicRequestBuilder.get(url).build();
if (lastModified != null) {
req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
}
if (eTag != null) {
req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
}
return req;
}
}
@Value
private static class HttpResponse {
private final int code;
private final String lastModifiedHeader;
private final String eTagHeader;
private final byte[] content;
private final String contentType;
private final String urlAfterRedirect;
int code;
String lastModifiedHeader;
String eTagHeader;
byte[] content;
String contentType;
String urlAfterRedirect;
}
@Getter
@RequiredArgsConstructor
@Value
public static class HttpResult {
private final byte[] content;
private final String contentType;
private final String lastModifiedSince;
private final String eTag;
private final String urlAfterRedirect;
byte[] content;
String contentType;
String lastModifiedSince;
String eTag;
String urlAfterRedirect;
}
}

View File

@@ -29,13 +29,15 @@ public class FeedDAO extends GenericDAO<Feed> {
}
public List<Feed> findNextUpdatable(int count, Instant lastLoginThreshold) {
JPAQuery<Feed> query = query().selectFrom(FEED).where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
JPAQuery<Feed> query = query().selectFrom(FEED)
.distinct()
// join on subscriptions to only refresh feeds that have subscribers
.join(SUBSCRIPTION)
.on(SUBSCRIPTION.feed.eq(FEED))
.where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
if (lastLoginThreshold != null) {
query.where(JPAExpressions.selectOne()
.from(SUBSCRIPTION)
.join(SUBSCRIPTION.user)
.where(SUBSCRIPTION.feed.id.eq(FEED.id), SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold))
.exists());
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
}
return query.orderBy(FEED.disabledUntil.asc()).limit(count).fetch();

View File

@@ -69,7 +69,7 @@ public class DefaultFaviconFetcher extends AbstractFaviconFetcher {
try {
url = FeedUtils.removeTrailingSlash(url) + "/favicon.ico";
log.debug("getting root icon at {}", url);
HttpResult result = getter.getBinary(url);
HttpResult result = getter.get(url);
bytes = result.getContent();
contentType = result.getContentType();
} catch (Exception e) {
@@ -87,7 +87,7 @@ public class DefaultFaviconFetcher extends AbstractFaviconFetcher {
Document doc;
try {
HttpResult result = getter.getBinary(url);
HttpResult result = getter.get(url);
doc = Jsoup.parse(new String(result.getContent()), url);
} catch (Exception e) {
log.debug("Failed to retrieve page to find icon");
@@ -113,7 +113,7 @@ public class DefaultFaviconFetcher extends AbstractFaviconFetcher {
byte[] bytes;
String contentType;
try {
HttpResult result = getter.getBinary(href);
HttpResult result = getter.get(href);
bytes = result.getContent();
contentType = result.getContentType();
} catch (Exception e) {

View File

@@ -43,7 +43,7 @@ public class FacebookFaviconFetcher extends AbstractFaviconFetcher {
try {
log.debug("Getting Facebook user's icon, {}", url);
HttpResult iconResult = getter.getBinary(iconUrl);
HttpResult iconResult = getter.get(iconUrl);
bytes = iconResult.getContent();
contentType = iconResult.getContentType();
} catch (Exception e) {

View File

@@ -78,7 +78,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
return null;
}
HttpResult iconResult = getter.getBinary(thumbnailUrl.asText());
HttpResult iconResult = getter.get(thumbnailUrl.asText());
bytes = iconResult.getContent();
contentType = iconResult.getContentType();
} catch (Exception e) {
@@ -97,7 +97,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
.queryParam("key", googleAuthKey)
.queryParam("forUsername", userId)
.build();
return getter.getBinary(uri.toString()).getContent();
return getter.get(uri.toString()).getContent();
}
private byte[] fetchForChannel(String googleAuthKey, String channelId) throws IOException, NotModifiedException {
@@ -106,7 +106,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
.queryParam("key", googleAuthKey)
.queryParam("id", channelId)
.build();
return getter.getBinary(uri.toString()).getContent();
return getter.get(uri.toString()).getContent();
}
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId) throws IOException, NotModifiedException {
@@ -115,7 +115,7 @@ public class YoutubeFaviconFetcher extends AbstractFaviconFetcher {
.queryParam("key", googleAuthKey)
.queryParam("id", playlistId)
.build();
byte[] playlistBytes = getter.getBinary(uri.toString()).getContent();
byte[] playlistBytes = getter.get(uri.toString()).getContent();
JsonNode channelId = objectMapper.readTree(playlistBytes).at(PLAYLIST_CHANNEL_ID);
if (channelId.isMissingNode()) {

View File

@@ -9,6 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import com.commafeed.backend.Digests;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpRequest;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.HttpGetter.NotModifiedException;
import com.commafeed.backend.feed.parser.FeedParser;
@@ -41,7 +42,7 @@ public class FeedFetcher {
Instant lastPublishedDate, String lastContentHash) throws FeedException, IOException, NotModifiedException {
log.debug("Fetching feed {}", feedUrl);
HttpResult result = getter.getBinary(feedUrl, lastModified, eTag);
HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
byte[] content = result.getContent();
FeedParserResult parserResult;
@@ -53,7 +54,7 @@ public class FeedFetcher {
if (org.apache.commons.lang3.StringUtils.isNotBlank(extractedUrl)) {
feedUrl = extractedUrl;
result = getter.getBinary(extractedUrl, lastModified, eTag);
result = getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build());
content = result.getContent();
parserResult = parser.parse(result.getUrlAfterRedirect(), content);
} else {

View File

@@ -54,9 +54,9 @@ public class FeedRefreshWorker {
entries = entries.stream().limit(maxFeedCapacity).toList();
}
Duration maxEntriesAgeDays = config.database().cleanup().entriesMaxAge();
if (!maxEntriesAgeDays.isZero()) {
Instant threshold = Instant.now().minus(maxEntriesAgeDays);
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
if (!entriesMaxAge.isZero()) {
Instant threshold = Instant.now().minus(entriesMaxAge);
entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList();
}

View File

@@ -16,11 +16,9 @@ import org.netpreserve.urlcanon.Canonicalizer;
import org.netpreserve.urlcanon.ParsedUrl;
import com.commafeed.backend.feed.FeedEntryKeyword.Mode;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.feed.parser.TextDirectionDetector;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.frontend.model.Entry;
import com.google.gwt.i18n.client.HasDirection.Direction;
import com.google.gwt.i18n.shared.BidiUtils;
import lombok.extern.slf4j.Slf4j;
@@ -93,24 +91,18 @@ public class FeedUtils {
return normalized;
}
public static boolean isRTL(FeedEntry entry) {
String text = entry.getContent().getContent();
if (StringUtils.isBlank(text)) {
text = entry.getContent().getTitle();
}
public static boolean isRTL(String title, String content) {
String text = StringUtils.isNotBlank(content) ? content : title;
if (StringUtils.isBlank(text)) {
return false;
}
text = Jsoup.parse(text).text();
if (StringUtils.isBlank(text)) {
String stripped = Jsoup.parse(text).text();
if (StringUtils.isBlank(stripped)) {
return false;
}
Direction direction = BidiUtils.get().estimateDirection(text);
return direction == Direction.RTL;
return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT;
}
public static String removeTrailingSlash(String url) {

View File

@@ -0,0 +1,56 @@
package com.commafeed.backend.feed.parser;
import java.text.Bidi;
import java.util.regex.Pattern;
import org.apache.commons.lang3.math.NumberUtils;
public class TextDirectionDetector {
private static final Pattern WORDS_PATTERN = Pattern.compile("\\s+");
private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*");
private static final double RTL_THRESHOLD = 0.4D;
public enum Direction {
LEFT_TO_RIGHT, RIGHT_TO_LEFT
}
public static Direction detect(String input) {
if (input == null || input.isBlank()) {
return Direction.LEFT_TO_RIGHT;
}
long rtl = 0;
long total = 0;
for (String token : WORDS_PATTERN.split(input)) {
// skip urls
if (URL_PATTERN.matcher(token).matches()) {
continue;
}
// skip numbers
if (NumberUtils.isCreatable(token)) {
continue;
}
boolean requiresBidi = Bidi.requiresBidi(token.toCharArray(), 0, token.length());
if (requiresBidi) {
Bidi bidi = new Bidi(token, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT);
if (bidi.getBaseLevel() == 1) {
rtl++;
}
}
total++;
}
if (total == 0) {
return Direction.LEFT_TO_RIGHT;
}
double ratio = (double) rtl / total;
return ratio > RTL_THRESHOLD ? Direction.RIGHT_TO_LEFT : Direction.LEFT_TO_RIGHT;
}
}

View File

@@ -6,8 +6,12 @@ import java.util.Set;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.hibernate.annotations.JdbcTypeCode;
import com.commafeed.backend.feed.FeedUtils;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Lob;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@@ -21,6 +25,10 @@ import lombok.Setter;
@Setter
public class FeedEntryContent extends AbstractModel {
public enum Direction {
ltr, rtl, unknown
}
@Column(length = 2048)
private String title;
@@ -58,6 +66,10 @@ public class FeedEntryContent extends AbstractModel {
@Column(length = 4096)
private String categories;
@Column
@Enumerated(EnumType.STRING)
private Direction direction = Direction.unknown;
@OneToMany(mappedBy = "content")
private Set<FeedEntry> entries;
@@ -79,4 +91,14 @@ public class FeedEntryContent extends AbstractModel {
.build();
}
public boolean isRTL() {
if (direction == Direction.rtl) {
return true;
} else if (direction == Direction.ltr) {
return false;
} else {
// detect on the fly for content that was inserted before the direction field was added
return FeedUtils.isRTL(title, content);
}
}
}

View File

@@ -78,6 +78,8 @@ public class UserSettings extends AbstractModel {
@Column(nullable = false)
private ScrollMode scrollMode;
private int entriesToKeepOnTopWhenScrolling;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private IconDisplayMode starIconDisplayMode;

View File

@@ -47,6 +47,8 @@ public class FeedEntryContentService {
entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false));
entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096));
entryContent.setDirection(
FeedUtils.isRTL(content.title(), content.content()) ? FeedEntryContent.Direction.rtl : FeedEntryContent.Direction.ltr);
Enclosure enclosure = content.enclosure();
if (enclosure != null) {

View File

@@ -40,9 +40,14 @@ public class FeedSubscriptionService {
this.feedRefreshEngine = feedRefreshEngine;
this.config = config;
// automatically refresh feeds after they are subscribed to
// we need to use this hook because the feed needs to have been persisted because the queue processing is asynchronous
feedSubscriptionDAO.onPostCommitInsert(sub -> feedRefreshEngine.refreshImmediately(sub.getFeed()));
// automatically refresh new feeds after they are subscribed to
// we need to use this hook because the feed needs to have been persisted before being processed by the feed engine
feedSubscriptionDAO.onPostCommitInsert(sub -> {
Feed feed = sub.getFeed();
if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) {
feedRefreshEngine.refreshImmediately(feed);
}
});
}
public long subscribe(User user, String url, String title) {

View File

@@ -128,7 +128,7 @@ public class Entry implements Serializable {
entry.setTags(status.getTags().stream().map(FeedEntryTag::getName).toList());
if (content != null) {
entry.setRtl(FeedUtils.isRTL(feedEntry));
entry.setRtl(content.isRTL());
entry.setTitle(content.getTitle());
entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent());
entry.setAuthor(content.getAuthor());

View File

@@ -49,6 +49,9 @@ public class Settings implements Serializable {
requiredMode = RequiredMode.REQUIRED)
private String scrollMode;
@Schema(description = "number of entries to keep above the selected entry when scrolling", requiredMode = RequiredMode.REQUIRED)
private int entriesToKeepOnTopWhenScrolling;
@Schema(
description = "whether to show the star icon in the header of entries",
allowableValues = "always,never,on_desktop,on_mobile",

View File

@@ -77,7 +77,7 @@ public class ServerREST {
url = FeedUtils.imageProxyDecoder(url);
try {
HttpResult result = httpGetter.getBinary(url);
HttpResult result = httpGetter.get(url);
return Response.ok(result.getContent()).build();
} catch (Exception e) {
return Response.status(Status.SERVICE_UNAVAILABLE).entity(e.getMessage()).build();

View File

@@ -114,6 +114,7 @@ public class UserREST {
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(settings.getScrollMode().name());
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(settings.getStarIconDisplayMode().name());
s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode().name());
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
@@ -139,6 +140,7 @@ public class UserREST {
s.setLanguage("en");
s.setScrollSpeed(400);
s.setScrollMode(ScrollMode.if_needed.name());
s.setEntriesToKeepOnTopWhenScrolling(0);
s.setStarIconDisplayMode(IconDisplayMode.on_desktop.name());
s.setExternalLinkIconDisplayMode(IconDisplayMode.on_desktop.name());
s.setMarkAllAsReadConfirmation(true);
@@ -172,6 +174,7 @@ public class UserREST {
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(ScrollMode.valueOf(settings.getScrollMode()));
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(IconDisplayMode.valueOf(settings.getStarIconDisplayMode()));
s.setExternalLinkIconDisplayMode(IconDisplayMode.valueOf(settings.getExternalLinkIconDisplayMode()));
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());

View File

@@ -23,7 +23,6 @@ quarkus.http.auth.form.error-page=
quarkus.websocket.dispatch-to-worker=true
# database
quarkus.datasource.db-kind=h2
quarkus.liquibase.change-log=migrations.xml
quarkus.liquibase.migrate-at-start=true
@@ -32,6 +31,7 @@ quarkus.shutdown.timeout=5s
# native
quarkus.native.march=compatibility
quarkus.native.add-all-charsets=true
# dev profile overrides
@@ -39,6 +39,7 @@ quarkus.native.march=compatibility
%dev.quarkus.http.auth.session.encryption-key=123456789012345678901234567890
%dev.quarkus.log.category."com.commafeed".level=DEBUG
# %dev.quarkus.hibernate-orm.log.sql=true
%dev.commafeed.users.create-demo-account=true
# test profile overrides
@@ -47,6 +48,7 @@ quarkus.native.march=compatibility
%test.commafeed.users.create-demo-account=true
%test.commafeed.users.allow-registrations=true
%test.commafeed.password-recovery-enabled=true
%test.commafeed.http-client.cache.enabled=false
# prod profile overrides

View File

@@ -4,11 +4,12 @@
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="add-unread-count-settings" author="athou">
<validCheckSum>9:b4a4044ca0f7d9987536083943d4f1b4</validCheckSum>
<addColumn tableName="USERSETTINGS">
<column name="unreadCountTitle" type="BOOLEAN" value="false">
<column name="unreadCountTitle" type="BOOLEAN" valueBoolean="false">
<constraints nullable="false" />
</column>
<column name="unreadCountFavicon" type="BOOLEAN" value="true">
<column name="unreadCountFavicon" type="BOOLEAN" valueBoolean="true">
<constraints nullable="false" />
</column>
</addColumn>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="entriesToKeepOnTopWhenScrolling-setting" author="athou">
<addColumn tableName="USERSETTINGS">
<column name="entriesToKeepOnTopWhenScrolling" type="INT" valueNumeric="0">
<constraints nullable="false" />
</column>
</addColumn>
</changeSet>
<changeSet id="content-direction" author="athou">
<addColumn tableName="FEEDENTRYCONTENTS">
<column name="direction" type="varchar(16)" />
</addColumn>
</changeSet>
</databaseChangeLog>

View File

@@ -32,5 +32,6 @@
<include file="changelogs/db.changelog-4.3.xml" />
<include file="changelogs/db.changelog-4.4.xml" />
<include file="changelogs/db.changelog-5.1.xml" />
<include file="changelogs/db.changelog-5.2.xml" />
</databaseChangeLog>

View File

@@ -68,6 +68,9 @@ class HttpGetterTest {
Mockito.when(config.httpClient().responseTimeout()).thenReturn(Duration.ofSeconds(30));
Mockito.when(config.httpClient().connectionTimeToLive()).thenReturn(Duration.ofSeconds(30));
Mockito.when(config.httpClient().maxResponseSize()).thenReturn(new MemorySize(new BigInteger("10000")));
Mockito.when(config.httpClient().cache().enabled()).thenReturn(true);
Mockito.when(config.httpClient().cache().maximumMemorySize()).thenReturn(new MemorySize(new BigInteger("100000")));
Mockito.when(config.httpClient().cache().expiration()).thenReturn(Duration.ofMinutes(1));
Mockito.when(config.feedRefresh().httpThreads()).thenReturn(3);
this.getter = new HttpGetter(config, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
@@ -79,7 +82,7 @@ class HttpGetterTest {
void errorCodes(int code) {
this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withStatusCode(code));
HttpResponseException e = Assertions.assertThrows(HttpResponseException.class, () -> getter.getBinary(this.feedUrl));
HttpResponseException e = Assertions.assertThrows(HttpResponseException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals(code, e.getCode());
}
@@ -92,7 +95,7 @@ class HttpGetterTest {
.withHeader(HttpHeaders.LAST_MODIFIED, "123456")
.withHeader(HttpHeaders.ETAG, "78910"));
HttpResult result = getter.getBinary(this.feedUrl);
HttpResult result = getter.get(this.feedUrl);
Assertions.assertArrayEquals(feedContent, result.getContent());
Assertions.assertEquals(MediaType.APPLICATION_ATOM_XML.toString(), result.getContentType());
Assertions.assertEquals("123456", result.getLastModifiedSince());
@@ -121,7 +124,7 @@ class HttpGetterTest {
this.mockServerClient.when(HttpRequest.request().withMethod("GET").withPath("/redirected-2"))
.respond(HttpResponse.response().withBody(feedContent).withContentType(MediaType.APPLICATION_ATOM_XML));
HttpResult result = getter.getBinary(this.feedUrl);
HttpResult result = getter.get(this.feedUrl);
Assertions.assertEquals("http://localhost:" + this.mockServerClient.getPort() + "/redirected-2", result.getUrlAfterRedirect());
}
@@ -133,7 +136,7 @@ class HttpGetterTest {
this.mockServerClient.when(HttpRequest.request().withMethod("GET"))
.respond(HttpResponse.response().withDelay(Delay.milliseconds(1000)));
Assertions.assertThrows(SocketTimeoutException.class, () -> getter.getBinary(this.feedUrl));
Assertions.assertThrows(SocketTimeoutException.class, () -> getter.get(this.feedUrl));
}
@Test
@@ -142,7 +145,7 @@ class HttpGetterTest {
this.getter = new HttpGetter(config, Mockito.mock(CommaFeedVersion.class), Mockito.mock(MetricRegistry.class));
// try to connect to a non-routable address
// https://stackoverflow.com/a/904609
Assertions.assertThrows(ConnectTimeoutException.class, () -> getter.getBinary("http://10.255.255.1"));
Assertions.assertThrows(ConnectTimeoutException.class, () -> getter.get("http://10.255.255.1"));
}
@Test
@@ -150,7 +153,7 @@ class HttpGetterTest {
this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.USER_AGENT, "http-getter-test"))
.respond(HttpResponse.response().withBody("ok"));
HttpResult result = getter.getBinary(this.feedUrl);
HttpResult result = getter.get(this.feedUrl);
Assertions.assertEquals("ok", new String(result.getContent()));
}
@@ -159,7 +162,8 @@ class HttpGetterTest {
this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_MODIFIED_SINCE, "123456"))
.respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED));
Assertions.assertThrows(NotModifiedException.class, () -> getter.getBinary(this.feedUrl, "123456", null));
Assertions.assertThrows(NotModifiedException.class,
() -> getter.get(HttpGetter.HttpRequest.builder(this.feedUrl).lastModified("123456").build()));
}
@Test
@@ -167,7 +171,8 @@ class HttpGetterTest {
this.mockServerClient.when(HttpRequest.request().withMethod("GET").withHeader(HttpHeaders.IF_NONE_MATCH, "78910"))
.respond(HttpResponse.response().withStatusCode(HttpStatus.SC_NOT_MODIFIED));
Assertions.assertThrows(NotModifiedException.class, () -> getter.getBinary(this.feedUrl, null, "78910"));
Assertions.assertThrows(NotModifiedException.class,
() -> getter.get(HttpGetter.HttpRequest.builder(this.feedUrl).eTag("78910").build()));
}
@Test
@@ -184,18 +189,32 @@ class HttpGetterTest {
return HttpResponse.response().withBody("ok").withHeader(HttpHeaders.SET_COOKIE, "foo=bar");
});
Assertions.assertDoesNotThrow(() -> getter.getBinary(this.feedUrl));
Assertions.assertDoesNotThrow(() -> getter.getBinary(this.feedUrl));
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl));
Assertions.assertDoesNotThrow(() -> getter.get(this.feedUrl + "?foo=bar"));
Assertions.assertEquals(2, calls.get());
}
@Test
void cacheSubsequentCalls() throws IOException, NotModifiedException {
AtomicInteger calls = new AtomicInteger();
this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(req -> {
calls.incrementAndGet();
return HttpResponse.response().withBody("ok");
});
HttpResult result = getter.get(this.feedUrl);
Assertions.assertEquals(result, getter.get(this.feedUrl));
Assertions.assertEquals(1, calls.get());
}
@Test
void largeFeedWithContentLengthHeader() {
byte[] bytes = new byte[100000];
Arrays.fill(bytes, (byte) 1);
this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withBody(bytes));
IOException e = Assertions.assertThrows(IOException.class, () -> getter.getBinary(this.feedUrl));
IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals("Response size (100000 bytes) exceeds the maximum allowed size (10000 bytes)", e.getMessage());
}
@@ -208,7 +227,7 @@ class HttpGetterTest {
.withBody(bytes)
.withConnectionOptions(ConnectionOptions.connectionOptions().withSuppressContentLengthHeader(true)));
IOException e = Assertions.assertThrows(IOException.class, () -> getter.getBinary(this.feedUrl));
IOException e = Assertions.assertThrows(IOException.class, () -> getter.get(this.feedUrl));
Assertions.assertEquals("Response size exceeds the maximum allowed size (10000 bytes)", e.getMessage());
}
@@ -216,7 +235,7 @@ class HttpGetterTest {
void ignoreInvalidSsl() throws Exception {
this.mockServerClient.when(HttpRequest.request().withMethod("GET")).respond(HttpResponse.response().withBody("ok"));
HttpResult result = getter.getBinary("https://localhost:" + this.mockServerClient.getPort());
HttpResult result = getter.get("https://localhost:" + this.mockServerClient.getPort());
Assertions.assertEquals("ok", new String(result.getContent()));
}
@@ -251,7 +270,7 @@ class HttpGetterTest {
return HttpResponse.response().withBody(output.toByteArray()).withHeader(HttpHeaders.CONTENT_ENCODING, encoding);
});
HttpResult result = getter.getBinary(HttpGetterTest.this.feedUrl);
HttpResult result = getter.get(HttpGetterTest.this.feedUrl);
Assertions.assertEquals(body, new String(result.getContent()));
}

View File

@@ -11,12 +11,12 @@ import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import com.commafeed.backend.Digests;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.HttpGetter.NotModifiedException;
import com.commafeed.backend.feed.parser.FeedParser;
import com.commafeed.backend.urlprovider.FeedURLProvider;
import com.google.gwt.thirdparty.guava.common.hash.Hashing;
@ExtendWith(MockitoExtension.class)
class FeedFetcherTest {
@@ -43,9 +43,9 @@ class FeedFetcherTest {
String lastModified = "last-modified-1";
String etag = "etag-1";
byte[] content = "content".getBytes();
String lastContentHash = Hashing.sha1().hashBytes(content).toString();
String lastContentHash = Digests.sha1Hex(content);
Mockito.when(getter.getBinary(url, lastModified, etag))
Mockito.when(getter.get(HttpGetter.HttpRequest.builder(url).lastModified(lastModified).eTag(etag).build()))
.thenReturn(new HttpResult(content, "content-type", "last-modified-2", "etag-2", null));
NotModifiedException e = Assertions.assertThrows(NotModifiedException.class,

View File

@@ -0,0 +1,53 @@
package com.commafeed.backend.feed.parser;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class TextDirectionDetectorTest {
@Test
public void testEstimateDirection() {
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect(""));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect(" "));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("! (...)"));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("Pure Ascii content"));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("-17.0%"));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT, TextDirectionDetector.detect("http://foo/bar/"));
Assertions.assertEquals(TextDirectionDetector.Direction.LEFT_TO_RIGHT,
TextDirectionDetector.detect("http://foo/bar/?s=\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0"
+ "\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0" + "\u05d0\u05d0\u05d0\u05d0\u05d0\u05d0"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector.detect("\u05d0"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector.detect("\u05d0"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("http://foo/bar/ \u05d0 http://foo2/bar2/ http://foo3/bar3/"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("\u05d0\u05d9\u05df \u05de\u05de\u05e9 " + "\u05de\u05d4 \u05dc\u05e8\u05d0\u05d5\u05ea: "
+ "\u05dc\u05d0 \u05e6\u05d9\u05dc\u05de\u05ea\u05d9 " + "\u05d4\u05e8\u05d1\u05d4 \u05d5\u05d2\u05dd \u05d0"
+ "\u05dd \u05d4\u05d9\u05d9\u05ea\u05d9 \u05de\u05e6\u05dc" + "\u05dd, \u05d4\u05d9\u05d4 \u05e9\u05dd"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("\u05db\u05d0\u05df - http://geek.co.il/gallery/v/2007-06"
+ " - \u05d0\u05d9\u05df \u05de\u05de\u05e9 \u05de\u05d4 " + "\u05dc\u05e8\u05d0\u05d5\u05ea: \u05dc\u05d0 \u05e6"
+ "\u05d9\u05dc\u05de\u05ea\u05d9 \u05d4\u05e8\u05d1\u05d4 "
+ "\u05d5\u05d2\u05dd \u05d0\u05dd \u05d4\u05d9\u05d9\u05ea"
+ "\u05d9 \u05de\u05e6\u05dc\u05dd, \u05d4\u05d9\u05d4 "
+ "\u05e9\u05dd \u05d1\u05e2\u05d9\u05e7\u05e8 \u05d4\u05e8" + "\u05d1\u05d4 \u05d0\u05e0\u05e9\u05d9\u05dd. \u05de"
+ "\u05d4 \u05e9\u05db\u05df - \u05d0\u05e4\u05e9\u05e8 " + "\u05dc\u05e0\u05e6\u05dc \u05d0\u05ea \u05d4\u05d4 "
+ "\u05d3\u05d6\u05de\u05e0\u05d5\u05ea \u05dc\u05d4\u05e1" + "\u05ea\u05db\u05dc \u05e2\u05dc \u05db\u05de\u05d4 "
+ "\u05ea\u05de\u05d5\u05e0\u05d5\u05ea \u05de\u05e9\u05e2"
+ "\u05e9\u05e2\u05d5\u05ea \u05d9\u05e9\u05e0\u05d5\u05ea " + "\u05d9\u05d5\u05ea\u05e8 \u05e9\u05d9\u05e9 \u05dc"
+ "\u05d9 \u05d1\u05d0\u05ea\u05e8"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("CAPTCHA \u05de\u05e9\u05d5\u05db\u05dc\u05dc " + "\u05de\u05d3\u05d9?"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("Yes Prime Minister \u05e2\u05d3\u05db\u05d5\u05df. "
+ "\u05e9\u05d0\u05dc\u05d5 \u05d0\u05d5\u05ea\u05d9 " + "\u05de\u05d4 \u05d0\u05e0\u05d9 \u05e8\u05d5\u05e6"
+ "\u05d4 \u05de\u05ea\u05e0\u05d4 \u05dc\u05d7\u05d2"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT, TextDirectionDetector
.detect("17.4.02 \u05e9\u05e2\u05d4:13-20 .15-00 .\u05dc\u05d0 " + "\u05d4\u05d9\u05d9\u05ea\u05d9 \u05db\u05d0\u05df."));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("5710 5720 5730. \u05d4\u05d3\u05dc\u05ea. " + "\u05d4\u05e0\u05e9\u05d9\u05e7\u05d4"));
Assertions.assertEquals(TextDirectionDetector.Direction.RIGHT_TO_LEFT,
TextDirectionDetector.detect("\u05d4\u05d3\u05dc\u05ea http://www.google.com " + "http://www.gmail.com"));
}
}

View File

@@ -5,7 +5,7 @@
<groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId>
<version>5.1.0</version>
<version>5.2.0</version>
<name>CommaFeed</name>
<packaging>pom</packaging>