-
Notifications
You must be signed in to change notification settings - Fork 0
Hunt #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Hunt #1
Changes from all commits
49c6b62
74176b0
7177725
3cfa366
67677a9
1dbb997
ddd629f
750acc6
e7430e3
4b6135c
964bb5e
9d06b50
68d7f1b
b7ff893
1a78488
44455a8
d15b733
7656c80
5c24cdc
d57f547
935754c
2cc3019
a3a398d
35644d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { promises as fs } from "node:fs"; | ||
| import { afterEach, beforeEach, describe, expect, it } from "vitest"; | ||
| import { loadBookmarks, saveBookmarks, type BookmarkItem } from "./bookmarks"; | ||
| import { bookmarksFile } from "../config/paths"; | ||
|
|
||
| describe("bookmarks persistence", () => { | ||
| beforeEach(async () => { | ||
| await fs.rm(bookmarksFile, { force: true }).catch(() => {}); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await fs.rm(bookmarksFile, { force: true }).catch(() => {}); | ||
| }); | ||
|
|
||
| it("saves and loads bookmark items", async () => { | ||
| const items: BookmarkItem[] = [ | ||
| { | ||
| id: "bm-1", | ||
| name: "Test Movie 1080p", | ||
| magnet: "magnet:?xt=urn:btih:1234567890abcdef", | ||
| source: "yts", | ||
| sizeBytes: 1500000000, | ||
| bookmarkedAt: Date.now(), | ||
| }, | ||
| ]; | ||
|
|
||
| await saveBookmarks(items); | ||
| const loaded = await loadBookmarks(); | ||
| expect(loaded).toHaveLength(1); | ||
| expect(loaded[0]?.id).toBe("bm-1"); | ||
| expect(loaded[0]?.name).toBe("Test Movie 1080p"); | ||
| }); | ||
|
|
||
| it("returns empty array when file does not exist", async () => { | ||
| const loaded = await loadBookmarks(); | ||
| expect(loaded).toEqual([]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { promises as fs, mkdirSync, writeFileSync, renameSync } from "node:fs"; | ||
| import path from "node:path"; | ||
| import { bookmarksFile } from "../config/paths"; | ||
| import { serializeWrites, writeJsonAtomic } from "../util/atomic"; | ||
| import type { SourceId } from "../sources/types"; | ||
|
|
||
| export const BOOKMARKS_CAP = 200; | ||
|
|
||
| export interface BookmarkItem { | ||
| id: string; | ||
| name: string; | ||
| source?: SourceId; | ||
| magnet: string; | ||
| sizeBytes: number; | ||
| bookmarkedAt: number; | ||
| } | ||
|
|
||
| const write = serializeWrites(); | ||
|
|
||
| export function saveBookmarks(items: BookmarkItem[]): Promise<void> { | ||
| return write(() => writeJsonAtomic(bookmarksFile, items.slice(0, BOOKMARKS_CAP))); | ||
| } | ||
|
|
||
| export function saveBookmarksSync(items: BookmarkItem[]): void { | ||
| try { | ||
| mkdirSync(path.dirname(bookmarksFile), { recursive: true }); | ||
| const tmp = `${bookmarksFile}.sync.tmp`; | ||
| writeFileSync(tmp, JSON.stringify(items.slice(0, BOOKMARKS_CAP), null, 2), "utf8"); | ||
| renameSync(tmp, bookmarksFile); | ||
| } catch {} | ||
| } | ||
|
|
||
| function isBookmarkItem(v: unknown): v is BookmarkItem { | ||
| if (!v || typeof v !== "object") return false; | ||
| const r = v as Record<string, unknown>; | ||
| return typeof r.id === "string" && typeof r.name === "string" && typeof r.magnet === "string"; | ||
| } | ||
|
|
||
| export async function loadBookmarks(): Promise<BookmarkItem[]> { | ||
| let raw: string; | ||
| try { | ||
| raw = await fs.readFile(bookmarksFile, "utf8"); | ||
| } catch { | ||
| return []; | ||
| } | ||
| try { | ||
| const parsed = JSON.parse(raw) as unknown; | ||
| return Array.isArray(parsed) ? parsed.filter(isBookmarkItem).slice(0, BOOKMARKS_CAP) : []; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { promises as fs } from "node:fs"; | ||
| import { afterEach, beforeEach, describe, expect, it } from "vitest"; | ||
| import { | ||
| addSearchQuery, | ||
| clearSearchHistory, | ||
| loadSearchHistory, | ||
| saveSearchHistory, | ||
| } from "./searchHistory"; | ||
| import { searchHistoryFile } from "../config/paths"; | ||
|
|
||
| describe("searchHistory module", () => { | ||
| beforeEach(async () => { | ||
| await fs.rm(searchHistoryFile, { force: true }).catch(() => {}); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await fs.rm(searchHistoryFile, { force: true }).catch(() => {}); | ||
| }); | ||
|
|
||
| describe("addSearchQuery", () => { | ||
| it("adds new search query to the front", () => { | ||
| const history = ["Inception", "Interstellar"]; | ||
| const result = addSearchQuery(history, "Oppenheimer"); | ||
| expect(result).toEqual(["Oppenheimer", "Inception", "Interstellar"]); | ||
| }); | ||
|
|
||
| it("moves existing duplicate to the front and avoids duplicate entries (case-insensitive)", () => { | ||
| const history = ["Oppenheimer", "Inception", "Interstellar"]; | ||
| const result = addSearchQuery(history, "inception"); | ||
| expect(result).toEqual(["inception", "Oppenheimer", "Interstellar"]); | ||
| }); | ||
|
|
||
| it("ignores empty or whitespace queries", () => { | ||
| const history = ["Inception"]; | ||
| expect(addSearchQuery(history, "")).toEqual(["Inception"]); | ||
| expect(addSearchQuery(history, " ")).toEqual(["Inception"]); | ||
| }); | ||
|
|
||
| it("ignores raw magnet links", () => { | ||
| const history = ["Inception"]; | ||
| expect( | ||
| addSearchQuery(history, "magnet:?xt=urn:btih:1234567890abcdef"), | ||
| ).toEqual(["Inception"]); | ||
| }); | ||
|
|
||
| it("enforces maxItems limit", () => { | ||
| const history = ["a", "b", "c"]; | ||
| const result = addSearchQuery(history, "d", 3); | ||
| expect(result).toEqual(["d", "a", "b"]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("persistence", () => { | ||
| it("saves and loads search history from disk", async () => { | ||
| const items = ["House of the Dragon", "The Batman", "Dune 2"]; | ||
| await saveSearchHistory(items); | ||
| const loaded = await loadSearchHistory(); | ||
| expect(loaded).toEqual(items); | ||
| }); | ||
|
|
||
| it("returns empty array when file does not exist", async () => { | ||
| const loaded = await loadSearchHistory(); | ||
| expect(loaded).toEqual([]); | ||
| }); | ||
|
|
||
| it("clears search history", async () => { | ||
| await saveSearchHistory(["House of the Dragon"]); | ||
| await clearSearchHistory(); | ||
| const loaded = await loadSearchHistory(); | ||
| expect(loaded).toEqual([]); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||||||||||||||||||||||||||
| import { promises as fs } from "node:fs"; | ||||||||||||||||||||||||||||||
| import { searchHistoryFile } from "../config/paths"; | ||||||||||||||||||||||||||||||
| import { serializeWrites, writeJsonAtomic } from "../util/atomic"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export const MAX_SEARCH_HISTORY = 50; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Pure helper to add a search query into history list: | ||||||||||||||||||||||||||||||
| * - Trims whitespace | ||||||||||||||||||||||||||||||
| * - Drops empty queries or magnet link URLs | ||||||||||||||||||||||||||||||
| * - Removes existing duplicate if present | ||||||||||||||||||||||||||||||
| * - Inserts the new query at the beginning (most recent first) | ||||||||||||||||||||||||||||||
| * - Limits the list to maxItems (default 50) | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| export function addSearchQuery( | ||||||||||||||||||||||||||||||
| history: string[], | ||||||||||||||||||||||||||||||
| query: string, | ||||||||||||||||||||||||||||||
| maxItems: number = MAX_SEARCH_HISTORY, | ||||||||||||||||||||||||||||||
| ): string[] { | ||||||||||||||||||||||||||||||
| const trimmed = query.trim(); | ||||||||||||||||||||||||||||||
| if (!trimmed) return history; | ||||||||||||||||||||||||||||||
| // Don't clutter search history with raw magnet URIs | ||||||||||||||||||||||||||||||
| if (/^magnet:\?/i.test(trimmed)) return history; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const withoutDuplicate = history.filter( | ||||||||||||||||||||||||||||||
| (item) => item.toLowerCase() !== trimmed.toLowerCase(), | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| return [trimmed, ...withoutDuplicate].slice(0, maxItems); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const write = serializeWrites(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export async function loadSearchHistory(): Promise<string[]> { | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| const raw = await fs.readFile(searchHistoryFile, "utf8"); | ||||||||||||||||||||||||||||||
| const parsed = JSON.parse(raw); | ||||||||||||||||||||||||||||||
| if (!Array.isArray(parsed)) return []; | ||||||||||||||||||||||||||||||
| return parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0); | ||||||||||||||||||||||||||||||
|
Comment on lines
+33
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Enforce the history cap during load.
Proposed fix- return parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
+ return parsed
+ .filter((item): item is string => typeof item === "string" && item.trim().length > 0)
+ .slice(0, MAX_SEARCH_HISTORY);📝 Committable suggestion
Suggested change
🧰 Tools🪛 ast-grep (0.45.0)[warning] 34-34: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use. (detect-non-literal-fs-filename-typescript) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export function saveSearchHistory(history: string[]): Promise<void> { | ||||||||||||||||||||||||||||||
| return write(() => writeJsonAtomic(searchHistoryFile, history)); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export function clearSearchHistory(): Promise<void> { | ||||||||||||||||||||||||||||||
| return saveSearchHistory([]); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Validate all required
BookmarkItemfields.loadBookmarksreturns values asBookmarkItem, but this guard accepts records withoutsizeBytesorbookmarkedAt. A malformed persistence file can then placeundefinedvalues in consumers that expect numbers.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents