From 7238d0cf80b36010700b87c2e9fbd91a23a9616d Mon Sep 17 00:00:00 2001 From: lcomplete Date: Tue, 20 Jan 2026 12:55:25 +0800 Subject: [PATCH 1/5] feat: reorder icons in Communication section for improved organization --- app/client/src/components/IconPicker/IconPicker.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/client/src/components/IconPicker/IconPicker.tsx b/app/client/src/components/IconPicker/IconPicker.tsx index 9b492f62..d0982c5e 100644 --- a/app/client/src/components/IconPicker/IconPicker.tsx +++ b/app/client/src/components/IconPicker/IconPicker.tsx @@ -163,9 +163,8 @@ const ICON_SECTIONS: Record> = { { icon: 'flat-color-icons:survey', name: 'Survey' }, ], 'Communication': [ - { icon: 'flat-color-icons:email', name: 'Email' }, - { icon: 'flat-color-icons:phone', name: 'Phone' }, { icon: 'flat-color-icons:sms', name: 'SMS' }, + { icon: 'flat-color-icons:phone', name: 'Phone' }, { icon: 'flat-color-icons:voicemail', name: 'Voicemail' }, { icon: 'flat-color-icons:faq', name: 'FAQ' }, { icon: 'flat-color-icons:news', name: 'News' }, @@ -176,7 +175,7 @@ const ICON_SECTIONS: Record> = { { icon: 'flat-color-icons:reading', name: 'Reading' }, { icon: 'flat-color-icons:reading-ebook', name: 'E-Book' }, { icon: 'flat-color-icons:feedback', name: 'Feedback' }, - { icon: 'flat-color-icons:questionnaire', name: 'Questionnaire' }, + { icon: 'flat-color-icons:survey', name: 'Survey' }, { icon: 'flat-color-icons:voice-presentation', name: 'Presentation' }, { icon: 'flat-color-icons:video-call', name: 'Video Call' }, ], From affd86eff4b1279b5098c1b040a0e9ac8e08d686 Mon Sep 17 00:00:00 2001 From: lcomplete Date: Tue, 20 Jan 2026 15:18:08 +0800 Subject: [PATCH 2/5] feat: add content type tagging for file naming and adjust filename length for tweets --- .../external/model/TweetProperties.java | 4 +- .../server/service/LibraryExportService.java | 39 +++++++++++++------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/model/TweetProperties.java b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/model/TweetProperties.java index ccf77936..e79ec42c 100644 --- a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/model/TweetProperties.java +++ b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/model/TweetProperties.java @@ -2,6 +2,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.time.Instant; @@ -125,10 +126,11 @@ public static class Variant{ @Getter @Setter + @NoArgsConstructor @AllArgsConstructor public static class Size{ private Integer width; - + private Integer height; } diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/service/LibraryExportService.java b/app/server/huntly-server/src/main/java/com/huntly/server/service/LibraryExportService.java index d46d854c..4f0f00d4 100644 --- a/app/server/huntly-server/src/main/java/com/huntly/server/service/LibraryExportService.java +++ b/app/server/huntly-server/src/main/java/com/huntly/server/service/LibraryExportService.java @@ -57,6 +57,7 @@ public class LibraryExportService { private static final int EXPORT_PAGE_SIZE = 100; private static final int EXPORT_HIGHLIGHT_PAGE_SIZE = 100; private static final int FILE_NAME_SNIPPET_MAX_LENGTH = 80; + private static final int TWEET_FILE_NAME_SNIPPET_MAX_LENGTH = 40; private static final int CONTENT_SNIPPET_MAX_LENGTH = 120; private static final DateTimeFormatter FILE_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") .withZone(ZoneOffset.UTC); @@ -314,16 +315,18 @@ private void exportHighlights(Path exportDir) throws IOException { } private void writePageMarkdown(Path categoryDir, Page page) throws IOException { + boolean isTweet = isTweetType(page); String title = resolveTitle(page.getTitle(), extractContentSnippet(page.getContent())); - String fileName = buildFileName(page.getId(), title); + String contentTypeTag = resolveContentTypeTag(page); + String fileName = buildFileName(page.getId(), contentTypeTag, title, isTweet); String markdownBody = buildPageMarkdown(page); - Map frontmatter = buildPageFrontmatter(page, title); + Map frontmatter = buildPageFrontmatter(page, title, isTweet); writeMarkdownFile(categoryDir.resolve(fileName), frontmatter, markdownBody); } private void writeHighlightMarkdown(Path highlightsDir, HighlightListItem highlight) throws IOException { String title = resolveTitle(highlight.getPageTitle(), highlight.getHighlightedText()); - String fileName = buildFileName(highlight.getId(), title); + String fileName = buildFileName(highlight.getId(), "highlight", title, false); Map frontmatter = new LinkedHashMap<>(); frontmatter.put("id", highlight.getId()); @@ -351,9 +354,8 @@ private String buildHighlightBody(HighlightListItem highlight) { return body.toString(); } - private Map buildPageFrontmatter(Page page, String title) { + private Map buildPageFrontmatter(Page page, String title, boolean isTweet) { Map frontmatter = new LinkedHashMap<>(); - boolean isTweet = isTweetType(page); frontmatter.put("id", page.getId()); // Tweet doesn't need title field @@ -715,15 +717,30 @@ private String extractContentSnippet(String content) { return text.substring(0, CONTENT_SNIPPET_MAX_LENGTH); } - private String buildFileName(Long id, String title) { - String safeTitle = sanitizeFileSegment(title); + private String buildFileName(Long id, String typeTag, String title, boolean isTweet) { + int maxLength = isTweet ? TWEET_FILE_NAME_SNIPPET_MAX_LENGTH : FILE_NAME_SNIPPET_MAX_LENGTH; + String safeTitle = sanitizeFileSegment(title, maxLength); if (StringUtils.isBlank(safeTitle)) { safeTitle = "untitled"; } - return id + "-" + safeTitle + ".md"; + return id + "-" + typeTag + "-" + safeTitle + ".md"; + } + + /** + * Resolve content type tag for file naming. + * Types: x (tweet), page (normal page), snippet + */ + private String resolveContentTypeTag(Page page) { + if (isTweetType(page)) { + return "x"; + } + if (page.getContentType() != null && Objects.equals(page.getContentType(), ContentType.SNIPPET.getCode())) { + return "snippet"; + } + return "page"; } - private String sanitizeFileSegment(String value) { + private String sanitizeFileSegment(String value, int maxLength) { if (StringUtils.isBlank(value)) { return ""; } @@ -773,8 +790,8 @@ private String sanitizeFileSegment(String value) { sanitized = sanitized.replaceAll("\\s+", " ").trim(); sanitized = sanitized.replaceAll("[. ]+$", ""); - if (sanitized.length() > FILE_NAME_SNIPPET_MAX_LENGTH) { - sanitized = sanitized.substring(0, FILE_NAME_SNIPPET_MAX_LENGTH).trim(); + if (sanitized.length() > maxLength) { + sanitized = sanitized.substring(0, maxLength).trim(); } return sanitized; From bdfd2a574aadc11e42e991d91a65f1b340e65dc5 Mon Sep 17 00:00:00 2001 From: lcomplete Date: Tue, 20 Jan 2026 21:30:46 +0800 Subject: [PATCH 3/5] feat: Implement batch organizing functionality for library pages - Added BatchOrganizeDialog component for selecting and moving pages between collections. - Introduced BatchOrganizeSetting component to filter and initiate batch moves. - Created BatchOrganizeController to handle API requests for filtering and moving pages. - Developed BatchOrganizeService to manage business logic for batch operations. - Implemented DTOs for batch filtering and moving requests/responses. - Enhanced PageRepository with batch update methods for collection management. - Updated PageService to handle library save status changes and timestamps. - Added pagination and selection features for improved user experience in batch operations. --- AGENTS.md | 5 + CLAUDE.md | 5 + README.md | 4 +- README.zh.md | 4 +- app/client/src/api/batchOrganize.ts | 125 ++++++++ .../src/components/BatchPageItemList.tsx | 136 ++++++++ .../Dialogs/BatchOrganizeDialog.tsx | 271 ++++++++++++++++ .../SettingModal/BatchOrganizeSetting.tsx | 290 ++++++++++++++++++ .../SettingModal/LibrarySetting.tsx | 8 +- .../external/dto/BatchFilterResult.java | 44 +++ .../external/dto/BatchMoveRequest.java | 44 +++ .../external/dto/BatchMoveResult.java | 31 ++ .../external/dto/BatchPageItem.java | 66 ++++ .../external/query/BatchFilterQuery.java | 72 +++++ .../controller/BatchOrganizeController.java | 42 +++ .../server/repository/PageRepository.java | 20 ++ .../server/service/BatchOrganizeService.java | 250 +++++++++++++++ .../huntly/server/service/PageService.java | 11 +- 18 files changed, 1419 insertions(+), 9 deletions(-) create mode 100644 app/client/src/api/batchOrganize.ts create mode 100644 app/client/src/components/BatchPageItemList.tsx create mode 100644 app/client/src/components/Dialogs/BatchOrganizeDialog.tsx create mode 100644 app/client/src/components/SettingModal/BatchOrganizeSetting.tsx create mode 100644 app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchFilterResult.java create mode 100644 app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveRequest.java create mode 100644 app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveResult.java create mode 100644 app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchPageItem.java create mode 100644 app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/query/BatchFilterQuery.java create mode 100644 app/server/huntly-server/src/main/java/com/huntly/server/controller/BatchOrganizeController.java create mode 100644 app/server/huntly-server/src/main/java/com/huntly/server/service/BatchOrganizeService.java diff --git a/AGENTS.md b/AGENTS.md index 2e86c5bd..cef4f135 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,8 @@ Follow Conventional Commits (`feat:`, `fix:`, optional scopes) in imperative voi ## Security & Configuration Tips Avoid committing SQLite artifacts in `app/server/huntly-server/db.sqlite*`; persist data through the `/data` volume when containerised. Store secrets in environment variables or Tauri keychains. Expose the API over HTTPS and review CORS settings before distributing new browser builds. + +## Documentation Guidelines +When updating the project's README, ensure all language versions are updated consistently: +- `README.md` (English) +- `README.zh.md` (Chinese) diff --git a/CLAUDE.md b/CLAUDE.md index 745a1780..0f7093d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,6 +181,11 @@ Key modules: - **Extension**: Jest with ts-jest for TypeScript support - **Client**: React Testing Library with Jest (via react-scripts) +## Documentation Guidelines +When updating the project's README, ensure all language versions are updated consistently: +- `README.md` (English) +- `README.zh.md` (Chinese) + ## Common Patterns - **API Controllers**: REST endpoints in `controller/` package - **Service Layer**: Business logic in `service/` package diff --git a/README.md b/README.md index f79e38d3..bfb4d021 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ Self-hosted information hub with a powerful browser extension that captures, pro ## Roadmap -- [ ] Export all saved content to Markdown +- [x] Export all saved content to Markdown +- [x] Flexible Organization: Collections - [ ] Enhanced extension with standalone AI processing (no server required) -- [ ] Flexible Organization: Tags, folders ## Screenshot diff --git a/README.zh.md b/README.zh.md index 17213cd6..71885c1f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -52,9 +52,9 @@ ## 路线图 -- [ ] 将所有保存的内容导出为 Markdown +- [x] 将所有保存的内容导出为 Markdown +- [x] 灵活的组织方式:收藏夹 - [ ] 增强扩展功能,支持独立 AI 处理(无需服务器) -- [ ] 灵活的组织方式:标签、文件夹 ## 系统截图 diff --git a/app/client/src/api/batchOrganize.ts b/app/client/src/api/batchOrganize.ts new file mode 100644 index 00000000..2dfc4229 --- /dev/null +++ b/app/client/src/api/batchOrganize.ts @@ -0,0 +1,125 @@ +import axios from "axios"; + +/** + * Simplified page item for batch organize display. + */ +export interface BatchPageItem { + id: number; + contentType: number; + title: string | null; + description: string | null; + url: string; + author: string | null; + pageJsonProperties: string | null; + collectedAt: string | null; + publishTime: string | null; +} + +/** + * Content type for filtering. + * - ALL: All content types + * - ARTICLE: Regular articles (BROWSER_HISTORY, MARKDOWN) + * - TWEET: Tweets (TWEET, QUOTED_TWEET) + * - SNIPPET: Page snippets + */ +export type ContentTypeFilter = "ALL" | "ARTICLE" | "TWEET" | "SNIPPET"; + +/** + * Collected time mode options. + * - KEEP: Keep original collected time (don't modify) + * - USE_PUBLISH_TIME: Set collected time to publish time (connectedAt) + */ +export type CollectedAtMode = "KEEP" | "USE_PUBLISH_TIME"; + +/** + * Query parameters for batch filtering. + */ +export interface BatchFilterQuery { + saveStatus?: "ALL" | "SAVED" | "ARCHIVED"; + contentType?: ContentTypeFilter; + collectionId?: number | null; // null means unsorted + filterUnsorted?: boolean; + starred?: boolean; + readLater?: boolean; + author?: string; // Filter by author (partial match) + startDate?: string; // Filter by createdAt (YYYY-MM-DD) + endDate?: string; // Filter by createdAt (YYYY-MM-DD) + page?: number; + size?: number; +} + +/** + * Result of batch filter query. + */ +export interface BatchFilterResult { + totalCount: number; + items: BatchPageItem[]; + currentPage: number; + totalPages: number; +} + +/** + * Request for batch moving pages to a collection. + */ +export interface BatchMoveRequest { + selectAll: boolean; + pageIds?: number[]; + filterQuery?: BatchFilterQuery; + targetCollectionId: number | null; + collectedAtMode: CollectedAtMode; +} + +/** + * Result of batch move operation. + */ +export interface BatchMoveResult { + successCount: number; + totalAffected: number; +} + +type ApiResult = { + code: number; + message?: string; + data: T; +}; + +/** + * Filter pages with pagination for batch operations. + */ +export async function filterPages( + query: BatchFilterQuery +): Promise { + const res = await axios.post>( + "/api/page/batch/filter", + query + ); + if (res.data.code !== 0) { + throw new Error(res.data.message || "Failed to filter pages."); + } + return res.data.data; +} + +/** + * Batch move pages to a collection. + */ +export async function batchMoveToCollection( + request: BatchMoveRequest +): Promise { + const res = await axios.post>( + "/api/page/batch/moveToCollection", + request + ); + if (res.data.code !== 0) { + throw new Error(res.data.message || "Failed to move pages."); + } + return res.data.data; +} + +/** + * Collected time mode options with labels for UI. + */ +export const COLLECTED_AT_MODE_OPTIONS: { value: CollectedAtMode; label: string }[] = [ + { value: "KEEP", label: "Keep Original" }, + { value: "USE_PUBLISH_TIME", label: "Use Publish Time" }, +]; + diff --git a/app/client/src/components/BatchPageItemList.tsx b/app/client/src/components/BatchPageItemList.tsx new file mode 100644 index 00000000..459cd16a --- /dev/null +++ b/app/client/src/components/BatchPageItemList.tsx @@ -0,0 +1,136 @@ +import { Box, Checkbox, IconButton, Tooltip } from "@mui/material"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { BatchPageItem } from "../api/batchOrganize"; +import SmartMoment from "./SmartMoment"; +import { TweetProperties } from "../interfaces/tweetProperties"; + +const TWEET_CONTENT_MAX_LENGTH = 100; + +// Helper to parse tweet properties +function parseTweetProps(item: BatchPageItem): TweetProperties | null { + const isTweet = item.contentType === 1 || item.contentType === 3; + if (!isTweet || !item.pageJsonProperties) return null; + try { + return JSON.parse(item.pageJsonProperties) as TweetProperties; + } catch { + return null; + } +} + +// Helper to get display content for an item +function getItemDisplayContent(item: BatchPageItem): { title: string; subtitle: string | null } { + const tweetProps = parseTweetProps(item); + if (tweetProps) { + const tweetStatus = tweetProps.retweetedTweet || tweetProps; + const fullText = tweetStatus.fullText || ""; + const truncated = fullText.length > TWEET_CONTENT_MAX_LENGTH + ? fullText.substring(0, TWEET_CONTENT_MAX_LENGTH) + "..." + : fullText; + return { title: truncated, subtitle: null }; + } + + return { + title: item.title || item.url, + subtitle: item.description + }; +} + +// Helper to get publish time - for tweets, fallback to createdAt in JSON if publishTime is null +function getPublishTime(item: BatchPageItem): string | null { + if (item.publishTime) return item.publishTime; + const tweetProps = parseTweetProps(item); + if (tweetProps) { + const tweetStatus = tweetProps.retweetedTweet || tweetProps; + return tweetStatus.createdAt || null; + } + return null; +} + +interface BatchPageItemListProps { + readonly items: readonly BatchPageItem[]; + /** Whether to show checkboxes for selection */ + readonly selectable?: boolean; + /** Set of selected item IDs */ + readonly selectedIds?: ReadonlySet; + /** Whether all items are globally selected (disables individual checkboxes) */ + readonly selectAll?: boolean; + /** Callback when an item's selection changes */ + readonly onSelectItem?: (id: number, selected: boolean) => void; +} + +export default function BatchPageItemList({ + items, + selectable = false, + selectedIds = new Set(), + selectAll = false, + onSelectItem, +}: BatchPageItemListProps) { + return ( + + {items.map((item) => { + const { title, subtitle } = getItemDisplayContent(item); + const pubTime = getPublishTime(item); + const isSelected = selectAll || selectedIds.has(item.id); + + return ( + + {/* Checkbox */} + {selectable && ( + onSelectItem?.(item.id, e.target.checked)} + sx={{ mt: -0.5, ml: -0.5 }} + /> + )} + + {/* Content */} + + {/* Title with links */} + + + {title} + + + + + + + + + {/* Subtitle/Description */} + {subtitle && ( + {subtitle} + )} + + {/* Metadata */} + + {item.author && Author: {item.author}} + {item.collectedAt && Collected: } + {pubTime && Published: } + + + + ); + })} + + ); +} + diff --git a/app/client/src/components/Dialogs/BatchOrganizeDialog.tsx b/app/client/src/components/Dialogs/BatchOrganizeDialog.tsx new file mode 100644 index 00000000..1f0147c8 --- /dev/null +++ b/app/client/src/components/Dialogs/BatchOrganizeDialog.tsx @@ -0,0 +1,271 @@ +import { useState, useCallback, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Box, + Button, + Checkbox, + CircularProgress, + Divider, + FormControl, + FormControlLabel, + FormHelperText, + InputLabel, + ListSubheader, + MenuItem, + Pagination, + Select, + Typography, +} from "@mui/material"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import { useSnackbar } from "notistack"; +import { + BatchFilterQuery, + BatchFilterResult, + CollectedAtMode, + COLLECTED_AT_MODE_OPTIONS, + filterPages, +} from "../../api/batchOrganize"; +import BatchPageItemList from "../BatchPageItemList"; + +interface CollectionOption { + readonly id: number | null; + readonly name: string; + readonly isGroup?: boolean; + readonly depth?: number; +} + +interface BatchOrganizeDialogProps { + readonly open: boolean; + readonly onClose: () => void; + readonly filterQuery: BatchFilterQuery; + readonly targetCollectionId: number | null; + readonly collectedAtMode: CollectedAtMode; + readonly collectionOptions: readonly CollectionOption[]; + readonly onMove: (selectAll: boolean, pageIds: number[], targetCollectionId: number | null, collectedAtMode: CollectedAtMode) => void; +} + +const PAGE_SIZE = 20; + +export default function BatchOrganizeDialog({ + open, + onClose, + filterQuery, + targetCollectionId: initialTargetCollectionId, + collectedAtMode: initialCollectedAtMode, + collectionOptions, + onMove, +}: BatchOrganizeDialogProps) { + const { enqueueSnackbar } = useSnackbar(); + const [isLoading, setIsLoading] = useState(false); + const [result, setResult] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectAll, setSelectAll] = useState(false); + const [targetCollectionId, setTargetCollectionId] = useState(initialTargetCollectionId); + const [collectedAtMode, setCollectedAtMode] = useState(initialCollectedAtMode); + const [showCollectionError, setShowCollectionError] = useState(false); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setCurrentPage(0); + setSelectedIds(new Set()); + setSelectAll(false); + setTargetCollectionId(initialTargetCollectionId); + setCollectedAtMode(initialCollectedAtMode); + } + }, [open, initialTargetCollectionId, initialCollectedAtMode]); + + // Load data when page changes + useEffect(() => { + if (!open) return; + const loadData = async () => { + setIsLoading(true); + try { + const data = await filterPages({ ...filterQuery, page: currentPage, size: PAGE_SIZE }); + setResult(data); + } catch (error) { + console.error("Failed to load pages:", error); + } finally { + setIsLoading(false); + } + }; + loadData(); + }, [open, filterQuery, currentPage]); + + const handleSelectItem = useCallback((id: number, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + if (!checked) { + setSelectAll(false); + } + }, []); + + const handleSelectAllOnPage = useCallback((checked: boolean) => { + if (!result) return; + setSelectedIds((prev) => { + const next = new Set(prev); + result.items.forEach((item) => { + if (checked) { + next.add(item.id); + } else { + next.delete(item.id); + } + }); + return next; + }); + }, [result]); + + const handleSelectAllGlobal = useCallback((checked: boolean) => { + setSelectAll(checked); + if (!checked) { + setSelectedIds(new Set()); + } + }, []); + + const handleMove = useCallback(() => { + // Validate target collection is selected + if (targetCollectionId === null) { + setShowCollectionError(true); + enqueueSnackbar("Please select a target collection", { variant: "warning" }); + return; + } + setShowCollectionError(false); + if (selectAll) { + onMove(true, [], targetCollectionId, collectedAtMode); + } else { + onMove(false, Array.from(selectedIds), targetCollectionId, collectedAtMode); + } + }, [selectAll, selectedIds, onMove, targetCollectionId, collectedAtMode, enqueueSnackbar]); + + const allOnPageSelected = result?.items.every((item) => selectedIds.has(item.id)) ?? false; + const someOnPageSelected = result?.items.some((item) => selectedIds.has(item.id)) ?? false; + const selectionCount = selectAll ? (result?.totalCount ?? 0) : selectedIds.size; + + return ( + + Batch Organize Pages + + {/* Selection controls */} + + handleSelectAllGlobal(e.target.checked)} />} + label={`Select all ${result?.totalCount ?? 0} pages`} + /> + {!selectAll && selectedIds.size > 0 && ( + + {selectedIds.size} selected + + )} + + + {/* List */} + {isLoading ? ( + + + + ) : ( + <> + {/* Select all on this page */} + !selectAll && handleSelectAllOnPage(!allOnPageSelected)} + > + 0} + indeterminate={someOnPageSelected && !allOnPageSelected} + disabled={selectAll} + sx={{ ml: -0.5 }} + /> + Select all on this page + + + {/* Page items list */} + + + + + {/* Pagination */} + {result && result.totalPages > 1 && ( + + setCurrentPage(page - 1)} + color="primary" + /> + + )} + + )} + + + {/* Move Options and Actions - all in one row */} + + + + + + + Target Collection + + {showCollectionError && Please select a collection} + + + + Collected Time + + + + + + + + ); +} + diff --git a/app/client/src/components/SettingModal/BatchOrganizeSetting.tsx b/app/client/src/components/SettingModal/BatchOrganizeSetting.tsx new file mode 100644 index 00000000..ad730f78 --- /dev/null +++ b/app/client/src/components/SettingModal/BatchOrganizeSetting.tsx @@ -0,0 +1,290 @@ +import { useState, useCallback, useEffect } from "react"; +import { + Box, + Button, + Checkbox, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + TextField, + CircularProgress, + Alert, + Divider, + ListSubheader, +} from "@mui/material"; +import { useSnackbar } from "notistack"; +import SettingSectionTitle from "./SettingSectionTitle"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import SearchIcon from "@mui/icons-material/Search"; +import { + BatchFilterQuery, + BatchFilterResult, + ContentTypeFilter, + CollectedAtMode, + filterPages, + batchMoveToCollection, +} from "../../api/batchOrganize"; +import { CollectionApi, CollectionTreeVO, CollectionVO } from "../../api/collectionApi"; +import BatchOrganizeDialog from "../Dialogs/BatchOrganizeDialog"; +import BatchPageItemList from "../BatchPageItemList"; + +const CONTENT_TYPE_OPTIONS: { value: ContentTypeFilter; label: string }[] = [ + { value: "ALL", label: "All" }, + { value: "ARTICLE", label: "Article" }, + { value: "TWEET", label: "Tweet" }, + { value: "SNIPPET", label: "Snippet" }, +]; + +// Helper type for tree structure +interface CollectionOption { + id: number | null; + name: string; + isGroup?: boolean; + depth?: number; +} + +export default function BatchOrganizeSetting() { + const { enqueueSnackbar } = useSnackbar(); + + // Filter state + const [saveStatus, setSaveStatus] = useState<"ALL" | "SAVED" | "ARCHIVED">("ALL"); + const [contentType, setContentType] = useState("ALL"); + const [collectionId, setCollectionId] = useState("UNSORTED"); + const [starred, setStarred] = useState(false); + const [readLater, setReadLater] = useState(false); + const [author, setAuthor] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + // UI state + const [isLoading, setIsLoading] = useState(false); + const [filterResult, setFilterResult] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [collections, setCollections] = useState(null); + + // Load collections on mount + useEffect(() => { + CollectionApi.getTree() + .then(setCollections) + .catch((err) => console.error("Failed to load collections:", err)); + }, []); + + // Build collection tree options for select dropdown + const buildCollectionOptions = useCallback((tree: CollectionTreeVO | null): CollectionOption[] => { + if (!tree) return []; + const result: CollectionOption[] = []; + const addCollection = (c: CollectionVO, depth: number = 0) => { + result.push({ id: c.id, name: c.name, depth }); + c.children?.forEach((child) => addCollection(child, depth + 1)); + }; + tree.groups.forEach((g) => { + result.push({ id: null, name: g.name, isGroup: true, depth: 0 }); + g.collections.forEach((c) => addCollection(c, 1)); + }); + return result; + }, []); + + const collectionOptions = buildCollectionOptions(collections); + + const buildQuery = useCallback((): BatchFilterQuery => { + return { + saveStatus: saveStatus === "ALL" ? undefined : saveStatus, + contentType: contentType === "ALL" ? undefined : contentType, + collectionId: collectionId === "UNSORTED" ? undefined : collectionId ?? undefined, + filterUnsorted: collectionId === "UNSORTED" ? true : undefined, + starred: starred || undefined, + readLater: readLater || undefined, + author: author.trim() || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + page: 0, + size: 5, + }; + }, [saveStatus, contentType, collectionId, starred, readLater, author, startDate, endDate]); + + const handleFilter = useCallback(async () => { + setIsLoading(true); + try { + const result = await filterPages(buildQuery()); + setFilterResult(result); + } catch (error: any) { + enqueueSnackbar(error.message || "Failed to filter pages", { variant: "error" }); + } finally { + setIsLoading(false); + } + }, [buildQuery, enqueueSnackbar]); + + const handleViewMore = useCallback(() => { + setDialogOpen(true); + }, []); + + const handleBatchMove = useCallback(async ( + selectAll: boolean, + pageIds: number[], + dialogTargetCollectionId: number | null, + dialogCollectedAtMode: CollectedAtMode + ) => { + try { + const result = await batchMoveToCollection({ + selectAll, + pageIds: selectAll ? undefined : pageIds, + filterQuery: selectAll ? buildQuery() : undefined, + targetCollectionId: dialogTargetCollectionId, + collectedAtMode: dialogCollectedAtMode, + }); + enqueueSnackbar(`Successfully moved ${result.successCount} pages`, { variant: "success" }); + setDialogOpen(false); + handleFilter(); // Refresh preview + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Failed to move pages"; + enqueueSnackbar(errorMessage, { variant: "error" }); + } + }, [buildQuery, enqueueSnackbar, handleFilter]); + + return ( +
+ + Batch Organize + + + {/* Filter Form */} + + {/* Row 1: Collection, Library, Type */} + + + Collection + + + + + Library + + + + + Type + + + + + {/* Row 2: Date range */} + + setStartDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ width: 150 }} + /> + setEndDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ width: 150 }} + /> + + + {/* Row 3: Starred, Read Later */} + + setStarred(e.target.checked)} size="small" />} label="Starred" /> + setReadLater(e.target.checked)} size="small" />} label="Read Later" /> + + + {/* Row 4: Author */} + + setAuthor(e.target.value)} + sx={{ width: 280 }} + /> + + + {/* Row 5: Filter button */} + + + + + + + {/* Filter Result Preview */} + {filterResult && ( + + + Found {filterResult.totalCount} pages matching your criteria. + + {filterResult.items.length > 0 && ( + <> + + {filterResult.totalCount > 5 && ( + + )} + + )} + + + )} + + + {/* Batch Organize Dialog */} + setDialogOpen(false)} + filterQuery={buildQuery()} + targetCollectionId={null} + collectedAtMode="KEEP" + collectionOptions={collectionOptions} + onMove={handleBatchMove} + /> +
+ ); +} + diff --git a/app/client/src/components/SettingModal/LibrarySetting.tsx b/app/client/src/components/SettingModal/LibrarySetting.tsx index 50cb1ac8..be841c26 100644 --- a/app/client/src/components/SettingModal/LibrarySetting.tsx +++ b/app/client/src/components/SettingModal/LibrarySetting.tsx @@ -11,8 +11,10 @@ import { startLibraryExport } from "../../api/libraryExport"; import { TwitterSaveRulesSetting } from "./TwitterSaveRulesSetting"; +import BatchOrganizeSetting from "./BatchOrganizeSetting"; import DownloadIcon from '@mui/icons-material/Download'; import RuleIcon from '@mui/icons-material/Rule'; +import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove'; const POLL_INTERVAL_MS = 5000; @@ -241,13 +243,17 @@ export default function LibrarySetting() { } iconPosition="start" label="Save rules" {...a11yProps(0)} sx={{ minHeight: 48 }} /> - } iconPosition="start" label="Export" {...a11yProps(1)} sx={{ minHeight: 48 }} /> + } iconPosition="start" label="Batch Organize" {...a11yProps(1)} sx={{ minHeight: 48 }} /> + } iconPosition="start" label="Export" {...a11yProps(2)} sx={{ minHeight: 48 }} /> + + + diff --git a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchFilterResult.java b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchFilterResult.java new file mode 100644 index 00000000..3fc11c2c --- /dev/null +++ b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchFilterResult.java @@ -0,0 +1,44 @@ +package com.huntly.interfaces.external.dto; + +import lombok.Data; + +import java.util.List; + +/** + * Result of batch filter query with pagination. + * + * @author lcomplete + */ +@Data +public class BatchFilterResult { + + /** + * Total count of items matching the filter. + */ + private long totalCount; + + /** + * Simplified page items for current page. + */ + private List items; + + /** + * Current page number (0-based). + */ + private int currentPage; + + /** + * Total number of pages. + */ + private int totalPages; + + public static BatchFilterResult of(long totalCount, List items, int currentPage, int totalPages) { + BatchFilterResult result = new BatchFilterResult(); + result.setTotalCount(totalCount); + result.setItems(items); + result.setCurrentPage(currentPage); + result.setTotalPages(totalPages); + return result; + } +} + diff --git a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveRequest.java b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveRequest.java new file mode 100644 index 00000000..a3c4cdf0 --- /dev/null +++ b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveRequest.java @@ -0,0 +1,44 @@ +package com.huntly.interfaces.external.dto; + +import com.huntly.interfaces.external.query.BatchFilterQuery; +import lombok.Data; + +import java.util.List; + +/** + * Request for batch moving pages to a collection. + * + * @author lcomplete + */ +@Data +public class BatchMoveRequest { + + /** + * Whether to select all items matching the filter. + * If true, filterQuery is used; if false, pageIds is used. + */ + private boolean selectAll; + + /** + * List of page IDs to move (used when selectAll is false). + */ + private List pageIds; + + /** + * Filter query to determine which pages to move (used when selectAll is true). + */ + private BatchFilterQuery filterQuery; + + /** + * Target collection ID. Null means move to Unsorted. + */ + private Long targetCollectionId; + + /** + * How to set the collectedAt timestamp. + * Values: KEEP (keep original), UPDATE_NOW (set to current time), + * or a date field name (SAVED_AT, ARCHIVED_AT, CREATED_AT, CONNECTED_AT, etc.) + */ + private String collectedAtMode; +} + diff --git a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveResult.java b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveResult.java new file mode 100644 index 00000000..ac6f237b --- /dev/null +++ b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveResult.java @@ -0,0 +1,31 @@ +package com.huntly.interfaces.external.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Result of batch move operation. + * + * @author lcomplete + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BatchMoveResult { + + /** + * Number of pages successfully moved. + */ + private int successCount; + + /** + * Total number of pages affected by the operation. + */ + private int totalAffected; + + public static BatchMoveResult of(int successCount, int totalAffected) { + return new BatchMoveResult(successCount, totalAffected); + } +} + diff --git a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchPageItem.java b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchPageItem.java new file mode 100644 index 00000000..883321bb --- /dev/null +++ b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchPageItem.java @@ -0,0 +1,66 @@ +package com.huntly.interfaces.external.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** + * Simplified page item for batch organize display. + * + * @author lcomplete + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BatchPageItem { + + /** + * Page ID. + */ + private Long id; + + /** + * Content type (0: BROWSER_HISTORY, 1: TWEET, 2: MARKDOWN, 3: QUOTED_TWEET, 4: SNIPPET). + */ + private Integer contentType; + + /** + * Page title. + */ + private String title; + + /** + * Page description (truncated). + */ + private String description; + + /** + * Page URL. + */ + private String url; + + /** + * Author name. + */ + private String author; + + /** + * JSON properties for special content types (e.g., tweet data). + */ + private String pageJsonProperties; + + /** + * Collected time (when added to collection) as ISO string for frontend processing. + */ + private Instant collectedAt; + + /** + * Publish time (original publish date, maps to connectedAt) as ISO string. + */ + private Instant publishTime; +} + diff --git a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/query/BatchFilterQuery.java b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/query/BatchFilterQuery.java new file mode 100644 index 00000000..07b13280 --- /dev/null +++ b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/query/BatchFilterQuery.java @@ -0,0 +1,72 @@ +package com.huntly.interfaces.external.query; + +import lombok.Data; + +/** + * Query parameters for batch filtering pages in library. + * + * @author lcomplete + */ +@Data +public class BatchFilterQuery { + + /** + * Filter by library save status. + * Values: ALL (both), SAVED (My List), ARCHIVED (Archive) + */ + private String saveStatus; + + /** + * Content type to filter. + * Values: ALL, ARTICLE (includes BROWSER_HISTORY, MARKDOWN), TWEET (includes TWEET, QUOTED_TWEET), SNIPPET + */ + private String contentType; + + /** + * Filter by collection ID. + * - Positive number: specific collection + * - Null or not set: all collections + */ + private Long collectionId; + + /** + * Whether to filter for unsorted pages only (collectionId = null in database). + */ + private Boolean filterUnsorted; + + /** + * Filter for starred pages only. + */ + private Boolean starred; + + /** + * Filter for read later pages only. + */ + private Boolean readLater; + + /** + * Filter by author name (partial match, case-insensitive). + */ + private String author; + + /** + * Start date for createdAt filter (YYYY-MM-DD format). + */ + private String startDate; + + /** + * End date for createdAt filter (YYYY-MM-DD format). + */ + private String endDate; + + /** + * Page number (0-based). + */ + private Integer page; + + /** + * Page size (default 20). + */ + private Integer size; +} + diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/controller/BatchOrganizeController.java b/app/server/huntly-server/src/main/java/com/huntly/server/controller/BatchOrganizeController.java new file mode 100644 index 00000000..c626fcb1 --- /dev/null +++ b/app/server/huntly-server/src/main/java/com/huntly/server/controller/BatchOrganizeController.java @@ -0,0 +1,42 @@ +package com.huntly.server.controller; + +import com.huntly.common.api.ApiResult; +import com.huntly.interfaces.external.dto.BatchFilterResult; +import com.huntly.interfaces.external.dto.BatchMoveRequest; +import com.huntly.interfaces.external.dto.BatchMoveResult; +import com.huntly.interfaces.external.query.BatchFilterQuery; +import com.huntly.server.service.BatchOrganizeService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * Controller for batch organizing library pages. + * + * @author lcomplete + */ +@Validated +@RestController +@RequestMapping("/api/page/batch") +@RequiredArgsConstructor +public class BatchOrganizeController { + + private final BatchOrganizeService batchOrganizeService; + + /** + * Filter pages with pagination for batch operations. + */ + @PostMapping("/filter") + public ApiResult filterPages(@RequestBody BatchFilterQuery query) { + return ApiResult.ok(batchOrganizeService.filterPages(query)); + } + + /** + * Batch move pages to a collection. + */ + @PostMapping("/moveToCollection") + public ApiResult batchMoveToCollection(@RequestBody BatchMoveRequest request) { + return ApiResult.ok(batchOrganizeService.batchMoveToCollection(request)); + } +} + diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/repository/PageRepository.java b/app/server/huntly-server/src/main/java/com/huntly/server/repository/PageRepository.java index 0cfbae06..53e86107 100644 --- a/app/server/huntly-server/src/main/java/com/huntly/server/repository/PageRepository.java +++ b/app/server/huntly-server/src/main/java/com/huntly/server/repository/PageRepository.java @@ -114,4 +114,24 @@ List findByCollectionIdAndLibrarySaveStatusGreaterThanOrderBySavedAtDesc(L */ @Query("SELECT p FROM Page p WHERE p.collectionId IS NULL AND p.librarySaveStatus > 0 ORDER BY p.savedAt DESC") List findUnsortedLibraryPages(Pageable pageable); + + /** + * Batch update collection only, keeping original collectedAt. + * Used for pages already in library. + */ + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Page p SET p.collectionId = :collectionId WHERE p.id IN :ids") + int batchUpdateCollection(@Param("ids") List ids, @Param("collectionId") Long collectionId); + + /** + * Batch update collection and set collectedAt to connectedAt (publish time). + * Used for pages already in library. + */ + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Page p SET p.collectionId = :collectionId, " + + "p.collectedAt = COALESCE(p.connectedAt, :fallback) " + + "WHERE p.id IN :ids") + int batchUpdateCollectionWithPublishTime(@Param("ids") List ids, @Param("collectionId") Long collectionId, @Param("fallback") Instant fallback); } \ No newline at end of file diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/service/BatchOrganizeService.java b/app/server/huntly-server/src/main/java/com/huntly/server/service/BatchOrganizeService.java new file mode 100644 index 00000000..0eba980c --- /dev/null +++ b/app/server/huntly-server/src/main/java/com/huntly/server/service/BatchOrganizeService.java @@ -0,0 +1,250 @@ +package com.huntly.server.service; + +import com.huntly.interfaces.external.dto.BatchFilterResult; +import com.huntly.interfaces.external.dto.BatchMoveRequest; +import com.huntly.interfaces.external.dto.BatchMoveResult; +import com.huntly.interfaces.external.dto.BatchPageItem; +import com.huntly.interfaces.external.model.ContentType; +import com.huntly.interfaces.external.model.LibrarySaveStatus; +import com.huntly.interfaces.external.query.BatchFilterQuery; +import com.huntly.jpa.spec.Specifications; +import com.huntly.server.domain.entity.Page; +import com.huntly.server.repository.PageRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaUpdate; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for batch organizing pages in library. + * + * @author lcomplete + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BatchOrganizeService { + + private final PageRepository pageRepository; + + @PersistenceContext + private EntityManager entityManager; + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int DESCRIPTION_MAX_LENGTH = 150; + + /** + * Filter pages with pagination. + */ + public BatchFilterResult filterPages(BatchFilterQuery query) { + Specification spec = buildSpecification(query); + int page = query.getPage() != null ? query.getPage() : 0; + int size = query.getSize() != null ? query.getSize() : DEFAULT_PAGE_SIZE; + + long totalCount = pageRepository.count(spec); + int totalPages = (int) Math.ceil((double) totalCount / size); + + org.springframework.data.domain.Page pageResult = pageRepository.findAll(spec, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); + List items = pageResult.getContent().stream() + .map(this::toBatchPageItem) + .collect(Collectors.toList()); + + return BatchFilterResult.of(totalCount, items, page, totalPages); + } + + private BatchPageItem toBatchPageItem(Page p) { + return BatchPageItem.builder() + .id(p.getId()) + .contentType(p.getContentType()) + .title(p.getTitle()) + .description(truncateDescription(p.getDescription())) + .url(p.getUrl()) + .author(p.getAuthor()) + .pageJsonProperties(p.getPageJsonProperties()) + .collectedAt(p.getCollectedAt()) + .publishTime(p.getConnectedAt()) + .build(); + } + + private String truncateDescription(String description) { + if (StringUtils.isBlank(description)) { + return null; + } + if (description.length() <= DESCRIPTION_MAX_LENGTH) { + return description; + } + return description.substring(0, DESCRIPTION_MAX_LENGTH) + "..."; + } + + /** + * Batch move pages to a collection. + */ + @Transactional + public BatchMoveResult batchMoveToCollection(BatchMoveRequest request) { + if (request.isSelectAll()) { + // Use bulk SQL update for better performance + return batchMoveByFilter(request); + } else { + // Use bulk SQL update for selected IDs + return batchMoveByIds(request.getPageIds(), request.getTargetCollectionId(), request.getCollectedAtMode()); + } + } + + private BatchMoveResult batchMoveByFilter(BatchMoveRequest request) { + BatchFilterQuery query = request.getFilterQuery(); + Long targetCollectionId = request.getTargetCollectionId(); + String mode = StringUtils.isBlank(request.getCollectedAtMode()) ? "KEEP" : request.getCollectedAtMode().toUpperCase(); + Instant now = Instant.now(); + + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaUpdate update = cb.createCriteriaUpdate(Page.class); + Root root = update.from(Page.class); + + // Reuse the same specification used for querying + Specification spec = buildSpecification(query); + Predicate predicate = spec.toPredicate(root, null, cb); + if (predicate != null) { + update.where(predicate); + } + + // Set collectionId - pages are already in library, no need to update librarySaveStatus/savedAt + update.set(root.get("collectionId"), targetCollectionId); + + // Handle collectedAt based on mode + if ("USE_PUBLISH_TIME".equals(mode)) { + // Set collectedAt to publish time (connectedAt), fallback to now if null + update.set(root.get("collectedAt"), + cb.coalesce(root.get("connectedAt"), now).as(Instant.class)); + } + // KEEP mode: don't modify collectedAt + + int updated = entityManager.createQuery(update).executeUpdate(); + return BatchMoveResult.of(updated, updated); + } + + private BatchMoveResult batchMoveByIds(List pageIds, Long targetCollectionId, String collectedAtMode) { + if (pageIds == null || pageIds.isEmpty()) { + return BatchMoveResult.of(0, 0); + } + + Instant now = Instant.now(); + int updated; + String mode = StringUtils.isBlank(collectedAtMode) ? "KEEP" : collectedAtMode.toUpperCase(); + + if ("USE_PUBLISH_TIME".equals(mode)) { + updated = pageRepository.batchUpdateCollectionWithPublishTime(pageIds, targetCollectionId, now); + } else { + // KEEP mode: don't modify collectedAt, pages are already in library + updated = pageRepository.batchUpdateCollection(pageIds, targetCollectionId); + } + + return BatchMoveResult.of(updated, pageIds.size()); + } + + private Specification buildSpecification(BatchFilterQuery query) { + var specs = Specifications.and() + // Library save status filter + .predicate(StringUtils.isNotBlank(query.getSaveStatus()) && !"ALL".equalsIgnoreCase(query.getSaveStatus()), + buildSaveStatusSpec(query.getSaveStatus())) + // Must be in library (SAVED or ARCHIVED) + .gt("librarySaveStatus", LibrarySaveStatus.NOT_SAVED.getCode()) + // Content type filter + .predicate(StringUtils.isNotBlank(query.getContentType()) && !"ALL".equalsIgnoreCase(query.getContentType()), + buildContentTypeSpec(query.getContentType())) + // Collection filter + .eq(query.getCollectionId() != null && !Boolean.TRUE.equals(query.getFilterUnsorted()), + "collectionId", query.getCollectionId()) + .predicate(Boolean.TRUE.equals(query.getFilterUnsorted()), + Specifications.and().eq("collectionId", (Object) null).build()) + // Starred filter + .eq(Boolean.TRUE.equals(query.getStarred()), "starred", true) + // Read later filter + .eq(Boolean.TRUE.equals(query.getReadLater()), "readLater", true) + // Author filter (case-insensitive partial match) + .predicate(StringUtils.isNotBlank(query.getAuthor()), buildAuthorSpec(query.getAuthor())) + // Date range filter (always use createdAt) + .ge(StringUtils.isNotBlank(query.getStartDate()), "createdAt", convertDateToInstant(query.getStartDate(), 0)) + .lt(StringUtils.isNotBlank(query.getEndDate()), "createdAt", convertDateToInstant(query.getEndDate(), 1)); + + return specs.build(); + } + + private Specification buildSaveStatusSpec(String saveStatus) { + if (StringUtils.isBlank(saveStatus) || "ALL".equalsIgnoreCase(saveStatus)) { + return null; + } + if ("SAVED".equalsIgnoreCase(saveStatus)) { + return Specifications.and().eq("librarySaveStatus", LibrarySaveStatus.SAVED.getCode()).build(); + } + if ("ARCHIVED".equalsIgnoreCase(saveStatus)) { + return Specifications.and().eq("librarySaveStatus", LibrarySaveStatus.ARCHIVED.getCode()).build(); + } + return null; + } + + private Specification buildContentTypeSpec(String contentType) { + if (StringUtils.isBlank(contentType) || "ALL".equalsIgnoreCase(contentType)) { + return null; + } + List codes = new ArrayList<>(); + switch (contentType.toUpperCase()) { + case "ARTICLE": + codes.add(ContentType.BROWSER_HISTORY.getCode()); + codes.add(ContentType.MARKDOWN.getCode()); + // For articles, null contentType is also considered as article + return Specifications.or() + .in("contentType", codes) + .eq("contentType", (Object) null) + .build(); + case "TWEET": + codes.add(ContentType.TWEET.getCode()); + codes.add(ContentType.QUOTED_TWEET.getCode()); + return Specifications.and().in("contentType", codes).build(); + case "SNIPPET": + codes.add(ContentType.SNIPPET.getCode()); + return Specifications.and().in("contentType", codes).build(); + default: + return null; + } + } + + private Specification buildAuthorSpec(String author) { + if (StringUtils.isBlank(author)) { + return null; + } + // Case-insensitive partial match on author field + return (root, query, cb) -> cb.like(cb.lower(root.get("author")), "%" + author.toLowerCase() + "%"); + } + + private Instant convertDateToInstant(String strDate, int plusDay) { + if (StringUtils.isBlank(strDate)) { + return null; + } + try { + return LocalDate.parse(strDate).atStartOfDay(ZoneId.systemDefault()).toInstant() + .plus(plusDay, ChronoUnit.DAYS); + } catch (Exception e) { + log.warn("Failed to parse date: {}", strDate); + return null; + } + } +} + diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/service/PageService.java b/app/server/huntly-server/src/main/java/com/huntly/server/service/PageService.java index 3bcc3227..f34f8e29 100644 --- a/app/server/huntly-server/src/main/java/com/huntly/server/service/PageService.java +++ b/app/server/huntly-server/src/main/java/com/huntly/server/service/PageService.java @@ -114,14 +114,17 @@ private PageOperateResult toPageOperateResult(Page page) { public void setPageLibrarySaveStatus(Page page, LibrarySaveStatus librarySaveStatus) { page.setLibrarySaveStatus(librarySaveStatus.getCode()); - if (librarySaveStatus.getCode() == LibrarySaveStatus.SAVED.getCode()) { - page.setSavedAt(Instant.now()); + if (librarySaveStatus.getCode() == LibrarySaveStatus.SAVED.getCode() || librarySaveStatus.getCode() == LibrarySaveStatus.ARCHIVED.getCode()) { + if (librarySaveStatus.getCode() == LibrarySaveStatus.SAVED.getCode()) { + page.setSavedAt(Instant.now()); + } + if (librarySaveStatus.getCode() == LibrarySaveStatus.ARCHIVED.getCode()) { + page.setArchivedAt(Instant.now()); + } // Set collectedAt when first saved (page goes to Unsorted collection) if (page.getCollectedAt() == null) { page.setCollectedAt(Instant.now()); } - } else if (librarySaveStatus.getCode() == LibrarySaveStatus.ARCHIVED.getCode()) { - page.setArchivedAt(Instant.now()); } else if (librarySaveStatus.getCode() == LibrarySaveStatus.NOT_SAVED.getCode()) { // if not save, clear status page.setStarred(false); From 6bc6bc0895e4e8b91238231084b50d5a59a02c72 Mon Sep 17 00:00:00 2001 From: lcomplete Date: Tue, 20 Jan 2026 21:43:37 +0800 Subject: [PATCH 4/5] feat: add pageListSort prop to MagazineItem and enhance SmartMoment with tooltip support --- app/client/src/components/MagazineItem.tsx | 20 ++++++++++++++++++-- app/client/src/components/PageList.tsx | 3 ++- app/client/src/components/SmartMoment.tsx | 20 ++++++++++++++++---- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/app/client/src/components/MagazineItem.tsx b/app/client/src/components/MagazineItem.tsx index 025d5f93..44b79deb 100644 --- a/app/client/src/components/MagazineItem.tsx +++ b/app/client/src/components/MagazineItem.tsx @@ -22,13 +22,28 @@ import {GithubRepoProperties} from "../interfaces/githubRepoProperties"; import StarOutlineIcon from '@mui/icons-material/StarOutline'; import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined'; import AltRouteIcon from '@mui/icons-material/AltRoute'; +import {SORT_VALUE} from "../model"; + +const sortLabelMap: Record = { + 'CREATED_AT': 'Created', + 'ARCHIVED_AT': 'Archived', + 'LAST_READ_AT': 'Read', + 'READ_LATER_AT': 'Read later', + 'SAVED_AT': 'Saved', + 'STARRED_AT': 'Starred', + 'CONNECTED_AT': 'Published', + 'VOTE_SCORE': 'Created', + 'COLLECTED_AT': 'Collected', + 'UNSORTED_SAVED_AT': 'Collected', +}; type MagazineItemProps = { page: PageItem, onOperateSuccess?: (event: PageOperateEvent) => void, onPageSelect?: (event: any, id: number) => void, showMarkReadOption?: boolean, - currentVisit?: boolean + currentVisit?: boolean, + pageListSort?: SORT_VALUE } export default function MagazineItem({ @@ -37,6 +52,7 @@ export default function MagazineItem({ onPageSelect, showMarkReadOption, // todo currentVisit, + pageListSort, }: MagazineItemProps) { const [readed, setReaded] = useState(page.markRead); @@ -150,7 +166,7 @@ export default function MagazineItem({ } - + diff --git a/app/client/src/components/PageList.tsx b/app/client/src/components/PageList.tsx index bcbd91ac..bb6a2797 100644 --- a/app/client/src/components/PageList.tsx +++ b/app/client/src/components/PageList.tsx @@ -487,7 +487,8 @@ const PageList = (props: PageListProps) => { openPageDetail(e, id)}> + onPageSelect={(e, id) => openPageDetail(e, id)} + pageListSort={filters.sort}> ; } )} diff --git a/app/client/src/components/SmartMoment.tsx b/app/client/src/components/SmartMoment.tsx index 3dd3fbac..59d599c7 100644 --- a/app/client/src/components/SmartMoment.tsx +++ b/app/client/src/components/SmartMoment.tsx @@ -1,6 +1,12 @@ import moment from "moment"; +import Tooltip from "@mui/material/Tooltip"; -const SmartMoment = ({dt}) => { +type SmartMomentProps = { + dt: string | Date | number; + timeTypeLabel?: string; +} + +const SmartMoment = ({dt, timeTypeLabel}: SmartMomentProps) => { function getMoment() { let text = ""; if (moment(dt).isAfter(moment().add(-1, "d"))) { @@ -13,10 +19,16 @@ const SmartMoment = ({dt}) => { return text; } + const tooltipText = timeTypeLabel + ? `${timeTypeLabel}: ${moment(dt).format('a h:mm ll')}` + : moment(dt).format('a h:mm ll'); + return ( - - {getMoment()} - + + + {getMoment()} + + ) } From 5ea54aa2ffa2621b9db14a3037d0339bbe40e836 Mon Sep 17 00:00:00 2001 From: lcomplete Date: Wed, 21 Jan 2026 01:21:53 +0800 Subject: [PATCH 5/5] feat: enhance batch organizing functionality with improved error handling and pagination validation --- .../Dialogs/BatchOrganizeDialog.tsx | 4 ++- .../external/dto/BatchMoveRequest.java | 4 +-- .../server/repository/PageRepository.java | 5 +-- .../server/service/BatchOrganizeService.java | 33 ++++++++++++++----- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/client/src/components/Dialogs/BatchOrganizeDialog.tsx b/app/client/src/components/Dialogs/BatchOrganizeDialog.tsx index 1f0147c8..66cf10e9 100644 --- a/app/client/src/components/Dialogs/BatchOrganizeDialog.tsx +++ b/app/client/src/components/Dialogs/BatchOrganizeDialog.tsx @@ -89,12 +89,14 @@ export default function BatchOrganizeDialog({ setResult(data); } catch (error) { console.error("Failed to load pages:", error); + setResult(null); + enqueueSnackbar("Failed to load pages. Please try again.", { variant: "error" }); } finally { setIsLoading(false); } }; loadData(); - }, [open, filterQuery, currentPage]); + }, [open, filterQuery, currentPage, enqueueSnackbar]); const handleSelectItem = useCallback((id: number, checked: boolean) => { setSelectedIds((prev) => { diff --git a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveRequest.java b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveRequest.java index a3c4cdf0..d406d8df 100644 --- a/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveRequest.java +++ b/app/server/huntly-interfaces/src/main/java/com/huntly/interfaces/external/dto/BatchMoveRequest.java @@ -36,8 +36,8 @@ public class BatchMoveRequest { /** * How to set the collectedAt timestamp. - * Values: KEEP (keep original), UPDATE_NOW (set to current time), - * or a date field name (SAVED_AT, ARCHIVED_AT, CREATED_AT, CONNECTED_AT, etc.) + * Values: KEEP (keep original collectedAt), USE_PUBLISH_TIME (set to publish time / connectedAt). + * Default is KEEP if not specified. */ private String collectedAtMode; } diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/repository/PageRepository.java b/app/server/huntly-server/src/main/java/com/huntly/server/repository/PageRepository.java index 53e86107..f219d888 100644 --- a/app/server/huntly-server/src/main/java/com/huntly/server/repository/PageRepository.java +++ b/app/server/huntly-server/src/main/java/com/huntly/server/repository/PageRepository.java @@ -126,12 +126,13 @@ List findByCollectionIdAndLibrarySaveStatusGreaterThanOrderBySavedAtDesc(L /** * Batch update collection and set collectedAt to connectedAt (publish time). + * Fallback to original collectedAt if connectedAt is null. * Used for pages already in library. */ @Transactional @Modifying(clearAutomatically = true) @Query("UPDATE Page p SET p.collectionId = :collectionId, " + - "p.collectedAt = COALESCE(p.connectedAt, :fallback) " + + "p.collectedAt = COALESCE(p.connectedAt, p.collectedAt) " + "WHERE p.id IN :ids") - int batchUpdateCollectionWithPublishTime(@Param("ids") List ids, @Param("collectionId") Long collectionId, @Param("fallback") Instant fallback); + int batchUpdateCollectionWithPublishTime(@Param("ids") List ids, @Param("collectionId") Long collectionId); } \ No newline at end of file diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/service/BatchOrganizeService.java b/app/server/huntly-server/src/main/java/com/huntly/server/service/BatchOrganizeService.java index 0eba980c..d8eb724e 100644 --- a/app/server/huntly-server/src/main/java/com/huntly/server/service/BatchOrganizeService.java +++ b/app/server/huntly-server/src/main/java/com/huntly/server/service/BatchOrganizeService.java @@ -51,13 +51,22 @@ public class BatchOrganizeService { private static final int DEFAULT_PAGE_SIZE = 20; private static final int DESCRIPTION_MAX_LENGTH = 150; + private static final int MAX_PAGE_SIZE = 500; + /** * Filter pages with pagination. */ public BatchFilterResult filterPages(BatchFilterQuery query) { + if (query == null) { + return BatchFilterResult.of(0, List.of(), 0, 0); + } + Specification spec = buildSpecification(query); - int page = query.getPage() != null ? query.getPage() : 0; + + // Validate and clamp page/size to prevent invalid PageRequest + int page = query.getPage() != null ? Math.max(0, query.getPage()) : 0; int size = query.getSize() != null ? query.getSize() : DEFAULT_PAGE_SIZE; + size = Math.max(1, Math.min(size, MAX_PAGE_SIZE)); // Clamp between 1 and MAX_PAGE_SIZE long totalCount = pageRepository.count(spec); int totalPages = (int) Math.ceil((double) totalCount / size); @@ -110,9 +119,12 @@ public BatchMoveResult batchMoveToCollection(BatchMoveRequest request) { private BatchMoveResult batchMoveByFilter(BatchMoveRequest request) { BatchFilterQuery query = request.getFilterQuery(); + if (query == null) { + log.warn("batchMoveByFilter called with selectAll=true but filterQuery is null"); + return BatchMoveResult.of(0, 0); + } Long targetCollectionId = request.getTargetCollectionId(); String mode = StringUtils.isBlank(request.getCollectedAtMode()) ? "KEEP" : request.getCollectedAtMode().toUpperCase(); - Instant now = Instant.now(); CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaUpdate update = cb.createCriteriaUpdate(Page.class); @@ -130,9 +142,9 @@ private BatchMoveResult batchMoveByFilter(BatchMoveRequest request) { // Handle collectedAt based on mode if ("USE_PUBLISH_TIME".equals(mode)) { - // Set collectedAt to publish time (connectedAt), fallback to now if null + // Set collectedAt to publish time (connectedAt), fallback to original collectedAt if null update.set(root.get("collectedAt"), - cb.coalesce(root.get("connectedAt"), now).as(Instant.class)); + cb.coalesce(root.get("connectedAt"), root.get("collectedAt")).as(Instant.class)); } // KEEP mode: don't modify collectedAt @@ -145,12 +157,11 @@ private BatchMoveResult batchMoveByIds(List pageIds, Long targetCollection return BatchMoveResult.of(0, 0); } - Instant now = Instant.now(); int updated; String mode = StringUtils.isBlank(collectedAtMode) ? "KEEP" : collectedAtMode.toUpperCase(); if ("USE_PUBLISH_TIME".equals(mode)) { - updated = pageRepository.batchUpdateCollectionWithPublishTime(pageIds, targetCollectionId, now); + updated = pageRepository.batchUpdateCollectionWithPublishTime(pageIds, targetCollectionId); } else { // KEEP mode: don't modify collectedAt, pages are already in library updated = pageRepository.batchUpdateCollection(pageIds, targetCollectionId); @@ -160,6 +171,10 @@ private BatchMoveResult batchMoveByIds(List pageIds, Long targetCollection } private Specification buildSpecification(BatchFilterQuery query) { + // Parse dates upfront - only apply filter if parsing succeeds + Instant startInstant = convertDateToInstant(query.getStartDate(), 0); + Instant endInstant = convertDateToInstant(query.getEndDate(), 1); + var specs = Specifications.and() // Library save status filter .predicate(StringUtils.isNotBlank(query.getSaveStatus()) && !"ALL".equalsIgnoreCase(query.getSaveStatus()), @@ -180,9 +195,9 @@ private Specification buildSpecification(BatchFilterQuery query) { .eq(Boolean.TRUE.equals(query.getReadLater()), "readLater", true) // Author filter (case-insensitive partial match) .predicate(StringUtils.isNotBlank(query.getAuthor()), buildAuthorSpec(query.getAuthor())) - // Date range filter (always use createdAt) - .ge(StringUtils.isNotBlank(query.getStartDate()), "createdAt", convertDateToInstant(query.getStartDate(), 0)) - .lt(StringUtils.isNotBlank(query.getEndDate()), "createdAt", convertDateToInstant(query.getEndDate(), 1)); + // Date range filter - only add when parsing succeeds (non-null) + .ge(startInstant != null, "createdAt", startInstant) + .lt(endInstant != null, "createdAt", endInstant); return specs.build(); }