Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@

## 路线图

- [ ] 将所有保存的内容导出为 Markdown
- [x] 将所有保存的内容导出为 Markdown
- [x] 灵活的组织方式:收藏夹
- [ ] 增强扩展功能,支持独立 AI 处理(无需服务器)
- [ ] 灵活的组织方式:标签、文件夹

## 系统截图

Expand Down
125 changes: 125 additions & 0 deletions app/client/src/api/batchOrganize.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
code: number;
message?: string;
data: T;
};

/**
* Filter pages with pagination for batch operations.
*/
export async function filterPages(
query: BatchFilterQuery
): Promise<BatchFilterResult> {
const res = await axios.post<ApiResult<BatchFilterResult>>(
"/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<BatchMoveResult> {
const res = await axios.post<ApiResult<BatchMoveResult>>(
"/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" },
];

136 changes: 136 additions & 0 deletions app/client/src/components/BatchPageItemList.tsx
Original file line number Diff line number Diff line change
@@ -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<number>;
/** 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 (
<Box className="space-y-2">
{items.map((item) => {
const { title, subtitle } = getItemDisplayContent(item);
const pubTime = getPublishTime(item);
const isSelected = selectAll || selectedIds.has(item.id);

return (
<Box
key={item.id}
className="p-2 border rounded text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
sx={{ display: "flex", alignItems: "flex-start", gap: 1 }}
>
{/* Checkbox */}
{selectable && (
<Checkbox
size="small"
checked={isSelected}
disabled={selectAll}
onChange={(e) => onSelectItem?.(item.id, e.target.checked)}
sx={{ mt: -0.5, ml: -0.5 }}
/>
)}

{/* Content */}
<Box sx={{ flex: 1, minWidth: 0 }}>
{/* Title with links */}
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<a
href={`/page/${item.id}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium truncate hover:underline flex-1"
style={{ display: "block", color: "inherit" }}
>
{title}
</a>
<Tooltip title="Open original URL">
<IconButton
size="small"
href={item.url}
target="_blank"
rel="noopener noreferrer"
sx={{ flexShrink: 0, p: 0.5 }}
>
<OpenInNewIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</Box>

{/* Subtitle/Description */}
{subtitle && (
<Box className="text-xs text-gray-500 truncate mt-1">{subtitle}</Box>
)}

{/* Metadata */}
<Box className="text-xs text-gray-400 mt-1 flex gap-4 flex-wrap">
{item.author && <span>Author: {item.author}</span>}
{item.collectedAt && <span>Collected: <SmartMoment dt={item.collectedAt} /></span>}
{pubTime && <span>Published: <SmartMoment dt={pubTime} /></span>}
</Box>
</Box>
</Box>
);
})}
</Box>
);
}

Loading