From 866cdc4f9b32b864ae08ecf99c550746b0c4647b Mon Sep 17 00:00:00 2001 From: LilianBoulard Date: Sat, 10 Jan 2026 19:09:32 +0100 Subject: [PATCH 1/2] Improve ordering --- README.md | 40 +++++- client/routes/post/PostNavigation.scss | 55 ++++++++ client/routes/post/PostNavigation.tsx | 74 ++++++++++ client/routes/post/PostPage.tsx | 2 + client/routes/search/GalleryPopup.tsx | 9 +- configs.json | 3 +- server/controllers/posts.ts | 187 +++++++++++++++++++++---- server/helpers/configs.ts | 2 + server/routes/api.ts | 14 +- server/routes/apiTypes.ts | 8 ++ server/routes/pages.ts | 23 +-- 11 files changed, 366 insertions(+), 51 deletions(-) create mode 100644 client/routes/post/PostNavigation.scss create mode 100644 client/routes/post/PostNavigation.tsx diff --git a/README.md b/README.md index 507c00e..8955e9e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ possible)** - Searching by tags - Negative search - Ratings -- Sorting (date imported, rating, size, etc) +- Sorting (date imported, rating, size, tag-based ordering) +- Tag-based ordering for comics/manga (page, volume, chapter) +- Post navigation (previous/next buttons with keyboard support) - Searching tags and autocomplete - Notes and translation overlays - tag and post relations (parents/siblings, duplicates/alternatives) @@ -71,19 +73,46 @@ You can also use `?` to match for single character(eg: `?girl`) and `*` to match Patterns prefixed with `-` will be excluded from results. Patterns are matched against tag's name, but Hydrus's `namespace:subtag` syntax is also supported. -Additionally you can sort results by including `order:*` in query. Supported sorts are: `order:posted`(date), -`order:id`, `order:rating`, `order:size`. You can also append `_desc` or `_asc` to specify order(eg: `order:posted_asc`). -If not specified, post are sorted by date descending. +Additionally you can sort results by including `order:*` in query. Supported sorts are: `order:date`(date posted), +`order:id`, `order:score`(rating), `order:size`. You can also append `_desc` or `_asc` to specify order(eg: `order:date_asc`). +If not specified, posts are sorted by date descending. + +### Tag-based Ordering + +You can also sort by tag namespaces for reading comics/manga in order. By default, the following namespaces are +supported: `order:page`, `order:volume`, `order:chapter`, `order:part`. These sort by the numeric value of the +corresponding tags (e.g., `page:1`, `page:2`, `volume:1`). + +Multiple sort fields can be combined for hierarchical sorting: +- `series:example order:volume order:page` - Sort by volume first, then by page within each volume + +Posts without the specified tag are placed at the end (ascending) or beginning (descending) of results. +Non-numeric tag values (like `page:cover`) are treated as null and sorted accordingly. + +The list of sortable namespaces can be configured via `posts.tagSorts` in `configs.json`. + +### Post Navigation + +When clicking a post from search results, navigation buttons (Previous/Next) appear on the post page, allowing +you to browse through posts in order. You can also use arrow keys (Left/Right) for keyboard navigation. + +### Rating Filter If you use a numeric rating service and successfully imported the ratings, you can also filter posts by their ratings using `rating:` namespace. You can search posts with specific rating(`rating:3`), range(`rating:2-4`) or query posts that have not been rated(`rating:none`). +### System Tags + `system:` tags from Hydrus are not real tags and are not fully supported. Hybooru only supports `system:inbox`, `system:archive` and a non-standard `system:trash` for filtering posts that are respectively in inbox, are not in inbox and are in trash. You can use them in the blacklist/whitelist and you can also negate them using `-` prefix in searches. -Eg: `1girl blue_* -outdoors rating:3-5 order:rating_desc` +### Examples + +- `1girl blue_* -outdoors rating:3-5 order:score_desc` - Search with tag filters and sort by rating +- `series:example order:volume order:page` - Read a manga series in order +- `order:page_asc` - Sort all posts by page number ascending ## Configuration @@ -110,6 +139,7 @@ Hybooru's config is stored in `configs.json` file in the project's root director | posts.cachePages | number | `5` | Number of pages cached in single cache entry. | | posts.cacheRecords | number | `1024` | Max number of cache entries. | | posts.maxPreviewSize | number | `104857600` | Max size in bytes of post that can be previewed in post page/gallery. Default is 100MB. | +| posts.tagSorts | string[] | `["page", "volume", "chapter", "part"]` | List of tag namespaces that can be used for `order:*` sorting. Allows sorting posts by tag values (e.g., `order:page`). | | tags | object | _see below_ | Options related to tags. All tags below support wildcards. | | tags.services | (string/number)[] or null | `null` | List of names or ids of tag services to import. Use `null` to import from all services. | | tags.motd | string or object or null | `null` | Query used to search for random image displayed on main page. You can also specify object to specify different tags for different themes(use `light`, `dark` and `auto` as keys) | diff --git a/client/routes/post/PostNavigation.scss b/client/routes/post/PostNavigation.scss new file mode 100644 index 0000000..126856d --- /dev/null +++ b/client/routes/post/PostNavigation.scss @@ -0,0 +1,55 @@ +.PostNavigation { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem; + margin-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color, rgba(128, 128, 128, 0.3)); + + .nav-button { + display: flex; + align-items: center; + padding: 0.25rem 0.5rem; + border-radius: 4px; + text-decoration: none; + color: inherit; + transition: background-color 0.2s, opacity 0.2s; + cursor: pointer; + font-size: 0.9em; + + &:hover:not(.disabled) { + background-color: var(--hover-bg, rgba(128, 128, 128, 0.2)); + } + + &.disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .arrow { + font-size: 1.2em; + line-height: 1; + } + + &.prev .arrow { + margin-right: 0.25rem; + } + + &.next .arrow { + margin-left: 0.25rem; + } + + .label { + @media (max-width: 400px) { + display: none; + } + } + } + + .position { + font-size: 0.85em; + opacity: 0.7; + white-space: nowrap; + } +} diff --git a/client/routes/post/PostNavigation.tsx b/client/routes/post/PostNavigation.tsx new file mode 100644 index 0000000..0858ddb --- /dev/null +++ b/client/routes/post/PostNavigation.tsx @@ -0,0 +1,74 @@ +import React, { useCallback, useEffect } from "react"; +import { Link, useHistory, useLocation } from "react-router-dom"; +import { PostNavigationResponse } from "../../../server/routes/apiTypes"; +import { qsParse, qsStringify } from "../../helpers/utils"; +import "./PostNavigation.scss"; + +interface PostNavigationProps { + navigation: PostNavigationResponse; +} + +export default function PostNavigation({ navigation }: PostNavigationProps) { + const history = useHistory(); + const location = useLocation(); + const query = qsParse(location.search); + + const buildLink = useCallback((id: number | null) => { + if(id === null) return null; + return `/posts/${id}${qsStringify(query)}`; + }, [query]); + + const prevLink = buildLink(navigation.prev); + const nextLink = buildLink(navigation.next); + + // Keyboard navigation + useEffect(() => { + const onKeyDown = (ev: KeyboardEvent) => { + // Don't navigate if user is typing in an input + if(ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement) return; + + if(ev.key === "ArrowLeft" && prevLink) { + ev.preventDefault(); + history.push(prevLink); + } else if(ev.key === "ArrowRight" && nextLink) { + ev.preventDefault(); + history.push(nextLink); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [history, prevLink, nextLink]); + + return ( +
+ {prevLink ? ( + + + Previous + + ) : ( + + + Previous + + )} + + + {navigation.position + 1} / {navigation.total} + + + {nextLink ? ( + + Next + + + ) : ( + + Next + + + )} +
+ ); +} diff --git a/client/routes/post/PostPage.tsx b/client/routes/post/PostPage.tsx index a153969..34d0214 100644 --- a/client/routes/post/PostPage.tsx +++ b/client/routes/post/PostPage.tsx @@ -11,6 +11,7 @@ import Tags from "../../components/Tags"; import NotFoundPage from "../error/NotFoundPage"; import File from "./File"; import SourceLink from "./SourceLink"; +import PostNavigation from "./PostNavigation"; import "./PostPage.scss"; const RELATION_STRING: Record = { @@ -66,6 +67,7 @@ export default function PostPage() { + {pageData.navigation && } {rating}
Statistics diff --git a/client/routes/search/GalleryPopup.tsx b/client/routes/search/GalleryPopup.tsx index d5d85b2..7f8f87e 100644 --- a/client/routes/search/GalleryPopup.tsx +++ b/client/routes/search/GalleryPopup.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useReducer, useRef } from "react"; import { useHistory } from "react-router"; import { Link } from "react-router-dom"; import { PostSummary } from "../../../server/routes/apiTypes"; +import useQuery from "../../hooks/useQuery"; import File from "../post/File"; import "./GalleryPopup.scss"; @@ -14,6 +15,8 @@ interface GalleryPopupProps { export default function GalleryPopup({ posts, id, setId }: GalleryPopupProps) { const [header, toggleHeader] = useReducer(acc => !acc, true); const history = useHistory(); + let [query] = useQuery(); + query = query && `?query=${encodeURIComponent(query)}`; const offset = useRef(0); const velocity = useRef(0); const moving = useRef(false); @@ -128,7 +131,7 @@ export default function GalleryPopup({ posts, id, setId }: GalleryPopupProps) { const onKeyDown = (ev: KeyboardEvent) => { if(ev.key === "ArrowLeft" && hasLeft) setId(id - 1); else if(ev.key === "ArrowRight" && hasRight) setId(id + 1); - else if(ev.key === "Enter") history.push(`/posts/${posts[id].id}`); + else if(ev.key === "Enter") history.push(`/posts/${posts[id].id}${query}`); else if(ev.key === "Escape") setId(null); }; @@ -144,7 +147,7 @@ export default function GalleryPopup({ posts, id, setId }: GalleryPopupProps) { document.documentElement.removeEventListener("keydown", onKeyDown); document.documentElement.removeEventListener("wheel", onWheel); }; - }, [history, posts, id, setId, hasLeft, hasRight]); + }, [history, posts, id, setId, hasLeft, hasRight, query]); if(id === null || !posts[id]) return null; @@ -156,7 +159,7 @@ export default function GalleryPopup({ posts, id, setId }: GalleryPopupProps) {
- Open Post + Open Post
{leftPost &&
diff --git a/configs.json b/configs.json index 90aa6fd..f8d3022 100644 --- a/configs.json +++ b/configs.json @@ -22,7 +22,8 @@ "pageSize": 72, "cachePages": 5, "cacheRecords": 1024, - "maxPreviewSize": 104857600 + "maxPreviewSize": 104857600, + "tagSorts": ["page", "volume", "chapter", "part"] }, "tags": { "services": null, diff --git a/server/controllers/posts.ts b/server/controllers/posts.ts index 7dabe18..a2e96de 100644 --- a/server/controllers/posts.ts +++ b/server/controllers/posts.ts @@ -14,13 +14,19 @@ const CACHE_SIZE = configs.posts.pageSize * configs.posts.cachePages; const blankPattern = /^[_%]*%[_%]*$/; -const SORTS = { +const COLUMN_SORTS: Record = { date: "posted", id: "id", score: "rating", size: "size", }; +interface SortSpec { + type: 'column' | 'tag'; + field: string; + order: 'asc' | 'desc'; +} + interface SearchArgs { query?: string; page?: number; @@ -245,8 +251,7 @@ interface CacheKey { blacklist: string[]; md5: string[]; sha256: string[]; - sort: string; - order: string; + sorts: SortSpec[]; rating: undefined | null | [number, number]; inbox: undefined | boolean; trash: undefined | boolean; @@ -257,20 +262,19 @@ function getCacheKey(query: string): CacheKey { const parts = query.split(" ") .filter(p => !!p) .map(preparePattern); - + if(parts.length > MAX_PARTS) throw new HTTPError(400, `Query can have only up to ${MAX_PARTS} parts.`); - - const whitelist = []; - const blacklist = []; - const sha256 = []; - const md5 = []; - let sort = SORTS.date; - let order = "desc"; + + const whitelist: string[] = []; + const blacklist: string[] = []; + const sha256: string[] = []; + const md5: string[] = []; + const sorts: SortSpec[] = []; let rating: undefined | null | [number, number] = undefined; let inbox: undefined | boolean; let trash: undefined | boolean; let match: RegExpMatchArray | null = null; - + for(let part of parts) { if(part.startsWith("system:") || part.startsWith("-system:")) { if(part === "system:archive" || part === "-system:inbox") inbox = false; @@ -281,7 +285,8 @@ function getCacheKey(query: string): CacheKey { blacklist.push(part.slice(1)); } else if(part.startsWith("order:")) { part = part.slice(6); - + let order: 'asc' | 'desc' = "desc"; + if(part.endsWith("\\_asc")) { order = "asc"; part = part.slice(0, -5); @@ -290,9 +295,15 @@ function getCacheKey(query: string): CacheKey { order = "desc"; part = part.slice(0, -6); } - - if(!(part in SORTS)) throw new HTTPError(400, `Invalid sorting: ${part}, expected: ${Object.keys(SORTS).join(", ")}`); - sort = SORTS[part as keyof typeof SORTS]; + + if(part in COLUMN_SORTS) { + sorts.push({ type: 'column', field: COLUMN_SORTS[part], order }); + } else if(configs.posts.tagSorts.includes(part)) { + sorts.push({ type: 'tag', field: part, order }); + } else { + const validSorts = [...Object.keys(COLUMN_SORTS), ...configs.posts.tagSorts]; + throw new HTTPError(400, `Invalid sorting: ${part}, expected: ${validSorts.join(", ")}`); + } } else if(part === "rating:none") { rating = null; } else if(part.startsWith("sha256:")) { @@ -317,14 +328,18 @@ function getCacheKey(query: string): CacheKey { whitelist.push(part); } } - + + // Default sort if none specified + if(sorts.length === 0) { + sorts.push({ type: 'column', field: 'posted', order: 'desc' }); + } + return { whitelist, blacklist, sha256, md5, - sort, - order, + sorts, rating, inbox, trash, @@ -344,13 +359,13 @@ let postsCache: Record = {}; async function getCachedPosts(key: CacheKey, client?: PoolClient): Promise { const hashed = keyHasher.hash(key); - + if(postsCache[hashed]) { postsCache[hashed].lastUsed = Date.now(); return postsCache[hashed]; } - - let { whitelist, blacklist, sha256, md5, sort, order, offset, rating, inbox, trash } = key; + + let { whitelist, blacklist, sha256, md5, sorts, offset, rating, inbox, trash } = key; let extraWhere = SQL``; let from = SQL` @@ -466,10 +481,59 @@ async function getCachedPosts(key: CacheKey, client?: PoolClient): Promise acc.append(join), SQL``); + + // Ensure WHERE clause is properly formed (extraWhere always starts with AND) + if(whereNotNull.text === '' && extraWhere.text !== '') { + whereNotNull = SQL`WHERE TRUE`; + } + const result = await db.queryFirst(SQL` WITH `.append(whitelistCTE || SQL``).append(SQL` @@ -500,12 +564,10 @@ async function getCachedPosts(key: CacheKey, client?: PoolClient): Promise { + const key = getCacheKey(query); + + let position = -1; + let total = 0; + let prev: number | null = null; + let next: number | null = null; + + // Search through cache pages to find the post + let currentOffset = 0; + const maxIterations = 1000; // Safety limit + let iterations = 0; + + while(position === -1 && iterations < maxIterations) { + iterations++; + key.offset = currentOffset; + const cached = await getCachedPosts(key); + total = cached.total; + + const indexInPage = cached.posts.indexOf(postId); + if(indexInPage !== -1) { + position = currentOffset + indexInPage; + + // Get prev from current page or previous page + if(indexInPage > 0) { + prev = cached.posts[indexInPage - 1]; + } else if(currentOffset > 0) { + // Need to fetch previous page + key.offset = currentOffset - CACHE_SIZE; + const prevPage = await getCachedPosts(key); + prev = prevPage.posts[prevPage.posts.length - 1] || null; + } + + // Get next from current page or next page + if(indexInPage < cached.posts.length - 1) { + next = cached.posts[indexInPage + 1]; + } else if(position < total - 1) { + // Need to fetch next page + key.offset = currentOffset + CACHE_SIZE; + const nextPage = await getCachedPosts(key); + next = nextPage.posts[0] || null; + } + + break; + } + + // Post not in this page, check next + if(cached.posts.length < CACHE_SIZE || currentOffset + CACHE_SIZE >= total) { + // No more pages or reached the end + break; + } + + currentOffset += CACHE_SIZE; + } + + return { prev, next, position, total }; +} + export function clearCache() { postsCache = {}; } diff --git a/server/helpers/configs.ts b/server/helpers/configs.ts index 374db5d..aae4a43 100644 --- a/server/helpers/configs.ts +++ b/server/helpers/configs.ts @@ -30,6 +30,7 @@ interface Configs { cachePages: number, cacheRecords: number, maxPreviewSize: number, + tagSorts: string[], }, tags: { services: Array | null, @@ -82,6 +83,7 @@ let configs: Configs = { cachePages: 5, cacheRecords: 1024, maxPreviewSize: 104857600, + tagSorts: ["page", "volume", "chapter", "part"], }, tags: { services: null, diff --git a/server/routes/api.ts b/server/routes/api.ts index 3370fd5..a0026eb 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -6,7 +6,7 @@ import HTTPError from "../helpers/HTTPError"; import * as db from "../helpers/db"; import * as postsController from "../controllers/posts"; import * as tagsController from "../controllers/tags"; -import { PostsGetResponse, PostsSearchRequest, PostsSearchResponse, RegenDBRequest, RegenDBResponse, TagsSearchRequest, TagsSearchResponse } from "./apiTypes"; +import { PostNavigationResponse, PostsGetResponse, PostsSearchRequest, PostsSearchResponse, RegenDBRequest, RegenDBResponse, TagsSearchRequest, TagsSearchResponse } from "./apiTypes"; export const router = PromiseRouter(); @@ -23,11 +23,19 @@ router.post("/regendb", async (req, r router.get<{ id: string }, PostsGetResponse, any, any>("/post/:id", async (req, res) => { const id = parseInt(req.params.id); - + let result; if(!isNaN(id)) result = await postsController.get(parseInt(req.params.id)); else result = null; - + + res.json(result); +}); + +router.get<{ id: string }, PostNavigationResponse, any, { query?: string }>("/post/:id/navigation", async (req, res) => { + const id = parseInt(req.params.id); + if(isNaN(id)) throw new HTTPError(400, "Invalid post ID"); + + const result = await postsController.getNavigation(id, req.query.query || ""); res.json(result); }); diff --git a/server/routes/apiTypes.ts b/server/routes/apiTypes.ts index afdf255..41ec059 100644 --- a/server/routes/apiTypes.ts +++ b/server/routes/apiTypes.ts @@ -145,8 +145,16 @@ export interface PostsSearchPageData { results: PostSearchResults; } +export interface PostNavigationResponse { + prev: number | null; + next: number | null; + position: number; + total: number; +} + export interface PostPageData { post: Post | null; + navigation?: PostNavigationResponse; } export interface TagsSearchPageRequest { diff --git a/server/routes/pages.ts b/server/routes/pages.ts index 1fd5e80..cf818b9 100644 --- a/server/routes/pages.ts +++ b/server/routes/pages.ts @@ -9,34 +9,39 @@ import * as githubController from "../controllers/github"; import * as postsController from "../controllers/posts"; import * as globalController from "../controllers/global"; import * as tagsController from "../controllers/tags"; -import { IndexPageData, Post, PostPageData, PostsSearchPageData, PostsSearchPageRequest, PostSummary, RandomPageData, RandomPageRequest, SetThemeRequest, TagsSearchPageData, TagsSearchPageRequest } from "./apiTypes"; +import { IndexPageData, Post, PostNavigationResponse, PostPageData, PostsSearchPageData, PostsSearchPageRequest, PostSummary, RandomPageData, RandomPageRequest, SetThemeRequest, TagsSearchPageData, TagsSearchPageRequest } from "./apiTypes"; export const router = PromiseRouter(); -router.get<{ id: string }>('/posts/:id', async (req, res) => { +router.get<{ id: string }, any, any, { query?: string }>('/posts/:id', async (req, res) => { const id = parseInt(req.params.id); - + let post; if(!isNaN(id)) post = await postsController.get(parseInt(req.params.id)); else post = null; - + + let navigation: PostNavigationResponse | undefined; + if(post && req.query.query) { + navigation = await postsController.getNavigation(id, req.query.query); + } + const options: Options = { ogUrl: `${req.protocol}://${req.get('host')}/post/${req.params.id}`, }; - + if(post) { options.ogTitle = options.title = postTitle(post); - + const tags = Object.keys(post.tags); const namespaced = tags.filter(tag => tag.match(namespaceRegex)).sort(); const unnamespaced = tags.filter(tag => !tag.match(namespaceRegex)).sort(); options.ogDescription = [...namespaced, ...unnamespaced].slice(0, 128).map(prettifyTag).join(", "); - + addOGMedia(options, post); } - - res.react({ post }, options); + + res.react({ post, navigation }, options); }); router.get('/posts', async (req, res) => { From 854d77f9e9ce8554dca714b144ef71862106c4c2 Mon Sep 17 00:00:00 2001 From: LilianBoulard Date: Sat, 10 Jan 2026 19:46:55 +0100 Subject: [PATCH 2/2] Fix displayed number --- server/controllers/posts.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/controllers/posts.ts b/server/controllers/posts.ts index a2e96de..687b09f 100644 --- a/server/controllers/posts.ts +++ b/server/controllers/posts.ts @@ -534,6 +534,12 @@ async function getCachedPosts(key: CacheKey, client?: PoolClient): Promise