增加了基础的块功能
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# Editor Quick Checklist
|
||||||
|
|
||||||
|
1. Select 2-3 paragraphs, open block menu, convert to code block.
|
||||||
|
Expected: merged into 1 code block, selection remains on that block.
|
||||||
|
|
||||||
|
2. Select 2-3 contiguous blocks, click move up/down.
|
||||||
|
Expected: all selected blocks move together in one step.
|
||||||
|
|
||||||
|
3. Select 2-3 contiguous blocks, drag using the block handle.
|
||||||
|
Expected: drag preview shows "will move N blocks", all blocks move together.
|
||||||
|
|
||||||
|
4. Select multiple blocks, run copy/delete/duplicate from block menu.
|
||||||
|
Expected: operation applies to all selected blocks.
|
||||||
|
|
||||||
|
5. After each multi-block action, press `Ctrl/Cmd + Z` once.
|
||||||
|
Expected: the full action is reverted in one undo step.
|
||||||
|
|
||||||
|
6. Open block menu and expand "查看快捷键".
|
||||||
|
Expected: shortcuts are visible and menu remains usable.
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "node --no-warnings --experimental-strip-types scripts/run-editor-block-ops-tests.mjs",
|
||||||
"check": "npm run lint -- --max-warnings=0 && npx tsc --noEmit"
|
"check": "npm run lint -- --max-warnings=0 && npx tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
buildDropIndicatorText,
|
||||||
|
computeSpanTargetStart,
|
||||||
|
mergeBlockTexts,
|
||||||
|
moveContiguousSpan,
|
||||||
|
} from "../src/lib/editor-block-ops.ts";
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
() => assert.equal(mergeBlockTexts(["A", "B", "C"]), "A\n\nB\n\nC"),
|
||||||
|
() => assert.equal(computeSpanTargetStart(6, 2, 3, 3), 2),
|
||||||
|
() => assert.equal(computeSpanTargetStart(6, 3, 4, 1), 1),
|
||||||
|
() => assert.equal(computeSpanTargetStart(6, 1, 2, 5), 3),
|
||||||
|
() => {
|
||||||
|
const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 2, 3, 1);
|
||||||
|
assert.equal(moved.changed, true);
|
||||||
|
assert.equal(moved.targetStart, 1);
|
||||||
|
assert.deepEqual(moved.reordered, ["A", "C", "D", "B", "E"]);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 1, 2, 5);
|
||||||
|
assert.equal(moved.changed, true);
|
||||||
|
assert.equal(moved.targetStart, 3);
|
||||||
|
assert.deepEqual(moved.reordered, ["A", "D", "E", "B", "C"]);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 1, 3, 2);
|
||||||
|
assert.equal(moved.changed, false);
|
||||||
|
assert.equal(moved.targetStart, 1);
|
||||||
|
assert.deepEqual(moved.reordered, ["A", "B", "C", "D", "E"]);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
assert.equal(buildDropIndicatorText(0, 4), "将插入到第 1 块之前");
|
||||||
|
assert.equal(buildDropIndicatorText(2, 4), "将插入到第 3 块之前");
|
||||||
|
assert.equal(buildDropIndicatorText(9, 4), "将插入到文档末尾");
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, run] of cases.entries()) {
|
||||||
|
run();
|
||||||
|
console.log(`ok ${index + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`All ${cases.length} editor block operation checks passed.`);
|
||||||
@@ -353,3 +353,15 @@ ul[data-type="taskList"],
|
|||||||
/* Let pre handle the background */
|
/* Let pre handle the background */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ProseMirror > .block-drag-active {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: hsl(var(--accent) / 0.45);
|
||||||
|
box-shadow: inset 0 0 0 1px hsl(var(--border) / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror > .block-selected {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: linear-gradient(180deg, hsl(var(--accent) / 0.42), hsl(var(--accent) / 0.25));
|
||||||
|
box-shadow: inset 0 0 0 1px hsl(var(--ring) / 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Lock, Save, Sparkles, Type, Upload, Download, Loader2 } from "lucide-react";
|
import { Lock, Save, Sparkles, Type, Upload, Download, Loader2, History, Trash2 } from "lucide-react";
|
||||||
import { ResizableSidebar } from "@/components/sidebar";
|
import { ResizableSidebar } from "@/components/sidebar";
|
||||||
import { PromptManagement } from "@/components/settings/prompt-management";
|
import { PromptManagement } from "@/components/settings/prompt-management";
|
||||||
import { ImportProvider } from "@/components/import-context";
|
import { ImportProvider } from "@/components/import-context";
|
||||||
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store";
|
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store";
|
||||||
import { useConfirm } from "@/components/confirm-provider";
|
import { useConfirm } from "@/components/confirm-provider";
|
||||||
|
import { cleanupAllPageHistories, estimateAllPageHistoriesUsage, trimAllPageHistoriesToRecent } from "@/lib/page-history";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -21,6 +22,8 @@ export default function SettingsPage() {
|
|||||||
setTableLineHeight,
|
setTableLineHeight,
|
||||||
timezone,
|
timezone,
|
||||||
setTimezone,
|
setTimezone,
|
||||||
|
localHistory,
|
||||||
|
setLocalHistory,
|
||||||
aiConfig,
|
aiConfig,
|
||||||
setAIConfig,
|
setAIConfig,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
@@ -30,8 +33,15 @@ export default function SettingsPage() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [isRestoring, setIsRestoring] = useState(false);
|
const [isRestoring, setIsRestoring] = useState(false);
|
||||||
|
const [historyMsg, setHistoryMsg] = useState<string | null>(null);
|
||||||
|
const [trimRecentCount, setTrimRecentCount] = useState(10);
|
||||||
|
const [historyStats, setHistoryStats] = useState({ keys: 0, snapshots: 0, bytes: 0 });
|
||||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHistoryStats(estimateAllPageHistoriesUsage());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -151,6 +161,85 @@ export default function SettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
|
||||||
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
|
<History size={18} className="text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold md:text-xl">本地历史策略</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
控制浏览器本地历史快照占用空间。保存后会在后续写入时自动清理,也可立即手动清理一次。
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="space-y-2 text-sm">
|
||||||
|
<span className="font-medium">每篇最多保留: {localHistory.maxSnapshotsPerPage} 条</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="5"
|
||||||
|
max="200"
|
||||||
|
step="1"
|
||||||
|
value={localHistory.maxSnapshotsPerPage}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalHistory({ maxSnapshotsPerPage: parseInt(e.target.value, 10) })
|
||||||
|
}
|
||||||
|
className="w-full accent-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2 text-sm">
|
||||||
|
<span className="font-medium">保留时长: {localHistory.retentionDays} 天</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
step="1"
|
||||||
|
value={localHistory.retentionDays}
|
||||||
|
onChange={(e) => setLocalHistory({ retentionDays: parseInt(e.target.value, 10) })}
|
||||||
|
className="w-full accent-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<div>本地历史键: {historyStats.keys}</div>
|
||||||
|
<div>快照总数: {historyStats.snapshots}</div>
|
||||||
|
<div>估算占用: {(historyStats.bytes / 1024).toFixed(1)} KB</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const result = cleanupAllPageHistories();
|
||||||
|
setHistoryStats(estimateAllPageHistoriesUsage());
|
||||||
|
setHistoryMsg(`已清理本地历史键 ${result.touched} 项`);
|
||||||
|
}}
|
||||||
|
className="ui-btn-secondary"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
立即清理一次
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={trimRecentCount}
|
||||||
|
onChange={(e) => setTrimRecentCount(Math.max(1, Math.min(200, parseInt(e.target.value || "1", 10))))}
|
||||||
|
className="ui-input h-9 w-24"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const result = trimAllPageHistoriesToRecent(trimRecentCount);
|
||||||
|
setHistoryStats(estimateAllPageHistoriesUsage());
|
||||||
|
setHistoryMsg(
|
||||||
|
`已处理 ${result.touched} 项,删除快照 ${result.removedSnapshots} 条(每篇保留最近 ${trimRecentCount} 条)`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="ui-btn-secondary"
|
||||||
|
>
|
||||||
|
仅保留最近 N 条
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{historyMsg && <span className="text-xs text-muted-foreground">{historyMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
|
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
|
||||||
<div className="flex items-center gap-2 border-b pb-2">
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
<Sparkles size={18} className="text-primary" />
|
<Sparkles size={18} className="text-primary" />
|
||||||
|
|||||||
+1104
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
buildDropIndicatorText,
|
||||||
|
computeSpanTargetStart,
|
||||||
|
mergeBlockTexts,
|
||||||
|
moveContiguousSpan,
|
||||||
|
} from "./editor-block-ops";
|
||||||
|
|
||||||
|
test("mergeBlockTexts joins with blank lines", () => {
|
||||||
|
assert.equal(mergeBlockTexts(["A", "B", "C"]), "A\n\nB\n\nC");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computeSpanTargetStart keeps position when dropping into current span", () => {
|
||||||
|
const next = computeSpanTargetStart(6, 2, 3, 3);
|
||||||
|
assert.equal(next, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computeSpanTargetStart moves span upward", () => {
|
||||||
|
const next = computeSpanTargetStart(6, 3, 4, 1);
|
||||||
|
assert.equal(next, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computeSpanTargetStart moves span downward", () => {
|
||||||
|
const next = computeSpanTargetStart(6, 1, 2, 5);
|
||||||
|
assert.equal(next, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moveContiguousSpan reorders blocks upward", () => {
|
||||||
|
const source = ["A", "B", "C", "D", "E"];
|
||||||
|
const moved = moveContiguousSpan(source, 2, 3, 1);
|
||||||
|
assert.equal(moved.changed, true);
|
||||||
|
assert.equal(moved.targetStart, 1);
|
||||||
|
assert.deepEqual(moved.reordered, ["A", "C", "D", "B", "E"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moveContiguousSpan reorders blocks to document end", () => {
|
||||||
|
const source = ["A", "B", "C", "D", "E"];
|
||||||
|
const moved = moveContiguousSpan(source, 1, 2, 5);
|
||||||
|
assert.equal(moved.changed, true);
|
||||||
|
assert.equal(moved.targetStart, 3);
|
||||||
|
assert.deepEqual(moved.reordered, ["A", "D", "E", "B", "C"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moveContiguousSpan does not change on internal drop", () => {
|
||||||
|
const source = ["A", "B", "C", "D", "E"];
|
||||||
|
const moved = moveContiguousSpan(source, 1, 3, 2);
|
||||||
|
assert.equal(moved.changed, false);
|
||||||
|
assert.equal(moved.targetStart, 1);
|
||||||
|
assert.deepEqual(moved.reordered, source);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildDropIndicatorText covers start, middle and end", () => {
|
||||||
|
assert.equal(buildDropIndicatorText(0, 4), "将插入到第 1 块之前");
|
||||||
|
assert.equal(buildDropIndicatorText(2, 4), "将插入到第 3 块之前");
|
||||||
|
assert.equal(buildDropIndicatorText(9, 4), "将插入到文档末尾");
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export function mergeBlockTexts(texts: string[]): string {
|
||||||
|
return texts.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSpanTargetStart(
|
||||||
|
totalCount: number,
|
||||||
|
sourceStartIndex: number,
|
||||||
|
sourceEndIndex: number,
|
||||||
|
insertionIndex: number
|
||||||
|
): number {
|
||||||
|
if (totalCount <= 0) return 0;
|
||||||
|
if (sourceStartIndex < 0 || sourceEndIndex < sourceStartIndex || sourceEndIndex >= totalCount) return sourceStartIndex;
|
||||||
|
|
||||||
|
const movedCount = sourceEndIndex - sourceStartIndex + 1;
|
||||||
|
const clampedInsertion = Math.max(0, Math.min(totalCount, insertionIndex));
|
||||||
|
if (clampedInsertion >= sourceStartIndex && clampedInsertion <= sourceEndIndex + 1) return sourceStartIndex;
|
||||||
|
return clampedInsertion <= sourceStartIndex ? clampedInsertion : clampedInsertion - movedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveContiguousSpan<T>(
|
||||||
|
items: T[],
|
||||||
|
sourceStartIndex: number,
|
||||||
|
sourceEndIndex: number,
|
||||||
|
insertionIndex: number
|
||||||
|
): { reordered: T[]; targetStart: number; changed: boolean } {
|
||||||
|
if (items.length === 0) return { reordered: items, targetStart: 0, changed: false };
|
||||||
|
if (sourceStartIndex < 0 || sourceEndIndex < sourceStartIndex || sourceEndIndex >= items.length) {
|
||||||
|
return { reordered: items, targetStart: sourceStartIndex, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStart = computeSpanTargetStart(items.length, sourceStartIndex, sourceEndIndex, insertionIndex);
|
||||||
|
if (targetStart === sourceStartIndex) {
|
||||||
|
return { reordered: items, targetStart, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = items.slice(sourceStartIndex, sourceEndIndex + 1);
|
||||||
|
const remaining = [...items.slice(0, sourceStartIndex), ...items.slice(sourceEndIndex + 1)];
|
||||||
|
const reordered = [...remaining.slice(0, targetStart), ...moved, ...remaining.slice(targetStart)];
|
||||||
|
return { reordered, targetStart, changed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDropIndicatorText(insertionIndex: number, totalCount: number): string {
|
||||||
|
const clamped = Math.max(0, Math.min(totalCount, insertionIndex));
|
||||||
|
if (totalCount <= 0) return "将插入到文档开头";
|
||||||
|
if (clamped >= totalCount) return "将插入到文档末尾";
|
||||||
|
return `将插入到第 ${clamped + 1} 块之前`;
|
||||||
|
}
|
||||||
+173
-12
@@ -1,7 +1,10 @@
|
|||||||
import type { Page } from "@/lib/store";
|
import type { Page } from "@/lib/store";
|
||||||
|
import { useSettingsStore } from "@/lib/settings-store";
|
||||||
|
|
||||||
const MAX_SNAPSHOTS_PER_PAGE = 30;
|
const MAX_SNAPSHOTS_PER_PAGE = 30;
|
||||||
|
const RETENTION_DAYS = 30;
|
||||||
const MIN_SNAPSHOT_INTERVAL_MS = 15_000;
|
const MIN_SNAPSHOT_INTERVAL_MS = 15_000;
|
||||||
|
const HISTORY_KEY_PREFIX = "noteai:history:";
|
||||||
|
|
||||||
export type PageSnapshot = {
|
export type PageSnapshot = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -10,28 +13,67 @@ export type PageSnapshot = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getKey(pageId: string): string {
|
function getKey(pageId: string): string {
|
||||||
return `noteai:history:${pageId}`;
|
return `${HISTORY_KEY_PREFIX}${pageId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseStorage(): boolean {
|
function canUseStorage(): boolean {
|
||||||
return typeof window !== "undefined" && typeof localStorage !== "undefined";
|
return typeof window !== "undefined" && typeof localStorage !== "undefined";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSnapshot(item: unknown): item is PageSnapshot {
|
||||||
|
return (
|
||||||
|
!!item &&
|
||||||
|
typeof item === "object" &&
|
||||||
|
typeof (item as PageSnapshot).title === "string" &&
|
||||||
|
typeof (item as PageSnapshot).content === "string" &&
|
||||||
|
typeof (item as PageSnapshot).timestamp === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(value: number, fallback: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRetentionConfig() {
|
||||||
|
const settings = useSettingsStore.getState().localHistory;
|
||||||
|
return {
|
||||||
|
maxSnapshotsPerPage: clampInt(settings?.maxSnapshotsPerPage ?? MAX_SNAPSHOTS_PER_PAGE, MAX_SNAPSHOTS_PER_PAGE, 5, 200),
|
||||||
|
retentionDays: clampInt(settings?.retentionDays ?? RETENTION_DAYS, RETENTION_DAYS, 1, 365),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneSnapshots(snapshots: PageSnapshot[]): PageSnapshot[] {
|
||||||
|
const { maxSnapshotsPerPage, retentionDays } = getRetentionConfig();
|
||||||
|
const minTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
.filter((item) => {
|
||||||
|
const ts = new Date(item.timestamp).getTime();
|
||||||
|
if (Number.isNaN(ts)) return false;
|
||||||
|
return ts >= minTimestamp;
|
||||||
|
})
|
||||||
|
.slice(0, maxSnapshotsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPageHistory(pageId: string): PageSnapshot[] {
|
export function getPageHistory(pageId: string): PageSnapshot[] {
|
||||||
if (!canUseStorage()) return [];
|
if (!canUseStorage()) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(getKey(pageId));
|
const raw = localStorage.getItem(getKey(pageId));
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
|
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (!Array.isArray(parsed)) return [];
|
if (!Array.isArray(parsed)) return [];
|
||||||
return parsed.filter(
|
|
||||||
(item): item is PageSnapshot =>
|
const normalized = parsed.filter(isSnapshot);
|
||||||
item &&
|
const pruned = pruneSnapshots(normalized);
|
||||||
typeof item === "object" &&
|
|
||||||
typeof item.title === "string" &&
|
if (pruned.length !== normalized.length) {
|
||||||
typeof item.content === "string" &&
|
localStorage.setItem(getKey(pageId), JSON.stringify(pruned));
|
||||||
typeof item.timestamp === "string"
|
}
|
||||||
);
|
|
||||||
|
return pruned;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -39,6 +81,7 @@ export function getPageHistory(pageId: string): PageSnapshot[] {
|
|||||||
|
|
||||||
export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">): void {
|
export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">): void {
|
||||||
if (!canUseStorage()) return;
|
if (!canUseStorage()) return;
|
||||||
|
|
||||||
const history = getPageHistory(page.id);
|
const history = getPageHistory(page.id);
|
||||||
const latest = history[0];
|
const latest = history[0];
|
||||||
|
|
||||||
@@ -60,12 +103,130 @@ export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
...history,
|
...history,
|
||||||
].slice(0, MAX_SNAPSHOTS_PER_PAGE);
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(getKey(page.id), JSON.stringify(next));
|
localStorage.setItem(getKey(page.id), JSON.stringify(pruneSnapshots(next)));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore storage errors.
|
// Ignore storage errors.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cleanupAllPageHistories(): { touched: number } {
|
||||||
|
if (!canUseStorage()) return { touched: 0 };
|
||||||
|
|
||||||
|
const targets: string[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i += 1) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
|
||||||
|
targets.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let touched = 0;
|
||||||
|
for (const key of targets) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
touched += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = parsed.filter(isSnapshot);
|
||||||
|
const pruned = pruneSnapshots(normalized);
|
||||||
|
if (pruned.length === 0) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(key, JSON.stringify(pruned));
|
||||||
|
}
|
||||||
|
|
||||||
|
touched += 1;
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
touched += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { touched };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateAllPageHistoriesUsage(): {
|
||||||
|
keys: number;
|
||||||
|
snapshots: number;
|
||||||
|
bytes: number;
|
||||||
|
} {
|
||||||
|
if (!canUseStorage()) return { keys: 0, snapshots: 0, bytes: 0 };
|
||||||
|
|
||||||
|
let keys = 0;
|
||||||
|
let snapshots = 0;
|
||||||
|
let bytes = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i += 1) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
|
||||||
|
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) continue;
|
||||||
|
keys += 1;
|
||||||
|
bytes += raw.length * 2;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
snapshots += parsed.filter(isSnapshot).length;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse failures here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { keys, snapshots, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimAllPageHistoriesToRecent(limitPerPage: number): {
|
||||||
|
touched: number;
|
||||||
|
removedSnapshots: number;
|
||||||
|
} {
|
||||||
|
if (!canUseStorage()) return { touched: 0, removedSnapshots: 0 };
|
||||||
|
|
||||||
|
const limit = clampInt(limitPerPage, 10, 1, 200);
|
||||||
|
const targets: string[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i += 1) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
|
||||||
|
targets.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let touched = 0;
|
||||||
|
let removedSnapshots = 0;
|
||||||
|
|
||||||
|
for (const key of targets) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) continue;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
touched += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = parsed.filter(isSnapshot);
|
||||||
|
const prunedByPolicy = pruneSnapshots(normalized);
|
||||||
|
const next = prunedByPolicy.slice(0, limit);
|
||||||
|
removedSnapshots += Math.max(0, normalized.length - next.length);
|
||||||
|
if (next.length === 0) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(key, JSON.stringify(next));
|
||||||
|
}
|
||||||
|
touched += 1;
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
touched += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { touched, removedSnapshots };
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ interface SettingsState {
|
|||||||
lineHeight: number;
|
lineHeight: number;
|
||||||
tableLineHeight: number;
|
tableLineHeight: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
localHistory: {
|
||||||
|
maxSnapshotsPerPage: number;
|
||||||
|
retentionDays: number;
|
||||||
|
};
|
||||||
|
|
||||||
aiConfig: {
|
aiConfig: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
@@ -52,6 +56,7 @@ interface SettingsState {
|
|||||||
setLineHeight: (height: number) => void;
|
setLineHeight: (height: number) => void;
|
||||||
setTableLineHeight: (height: number) => void;
|
setTableLineHeight: (height: number) => void;
|
||||||
setTimezone: (timezone: string) => void;
|
setTimezone: (timezone: string) => void;
|
||||||
|
setLocalHistory: (config: Partial<{ maxSnapshotsPerPage: number; retentionDays: number }>) => void;
|
||||||
setAIConfig: (config: Partial<{ apiKey: string; baseURL: string; model: string }>) => void;
|
setAIConfig: (config: Partial<{ apiKey: string; baseURL: string; model: string }>) => void;
|
||||||
|
|
||||||
setPrompts: (prompts: AIPrompt[]) => void;
|
setPrompts: (prompts: AIPrompt[]) => void;
|
||||||
@@ -69,6 +74,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
tableLineHeight: 1.2,
|
tableLineHeight: 1.2,
|
||||||
timezone: "Asia/Shanghai",
|
timezone: "Asia/Shanghai",
|
||||||
|
localHistory: {
|
||||||
|
maxSnapshotsPerPage: 30,
|
||||||
|
retentionDays: 30,
|
||||||
|
},
|
||||||
|
|
||||||
aiConfig: {
|
aiConfig: {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
@@ -83,6 +92,13 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
setLineHeight: (height) => set({ lineHeight: height }),
|
setLineHeight: (height) => set({ lineHeight: height }),
|
||||||
setTableLineHeight: (height) => set({ tableLineHeight: height }),
|
setTableLineHeight: (height) => set({ tableLineHeight: height }),
|
||||||
setTimezone: (timezone) => set({ timezone }),
|
setTimezone: (timezone) => set({ timezone }),
|
||||||
|
setLocalHistory: (config) =>
|
||||||
|
set((state) => ({
|
||||||
|
localHistory: {
|
||||||
|
...state.localHistory,
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
})),
|
||||||
setAIConfig: (config) =>
|
setAIConfig: (config) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
aiConfig: {
|
aiConfig: {
|
||||||
@@ -108,6 +124,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
lineHeight: state.lineHeight,
|
lineHeight: state.lineHeight,
|
||||||
tableLineHeight: state.tableLineHeight,
|
tableLineHeight: state.tableLineHeight,
|
||||||
timezone: state.timezone,
|
timezone: state.timezone,
|
||||||
|
localHistory: state.localHistory,
|
||||||
aiConfig: {
|
aiConfig: {
|
||||||
baseURL: state.aiConfig.baseURL,
|
baseURL: state.aiConfig.baseURL,
|
||||||
model: state.aiConfig.model,
|
model: state.aiConfig.model,
|
||||||
|
|||||||
Reference in New Issue
Block a user