mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-23 19:49:56 +01:00
Merge pull request #9192 from streetwriters/core/note-history-title
Add support for saving title in note history sessions
This commit is contained in:
@@ -47,7 +47,7 @@ const HistoryItem = ({
|
||||
}: {
|
||||
index: number;
|
||||
items?: VirtualizedGrouping<HistorySession>;
|
||||
note?: Note;
|
||||
note: Note;
|
||||
}) => {
|
||||
const [item] = useDBItem(index, "noteHistory", items);
|
||||
const { colors } = useThemeColors();
|
||||
@@ -73,6 +73,7 @@ const HistoryItem = ({
|
||||
session: getDate(item.dateCreated, item.dateModified)
|
||||
}}
|
||||
content={content}
|
||||
note={note}
|
||||
/>
|
||||
),
|
||||
context: "note_history"
|
||||
@@ -135,7 +136,7 @@ export default function NoteHistory({
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ index }: { index: number }) => (
|
||||
<HistoryItem index={index} items={history} />
|
||||
<HistoryItem index={index} items={history} note={note} />
|
||||
),
|
||||
[history]
|
||||
);
|
||||
|
||||
@@ -37,21 +37,43 @@ import Paragraph from "../ui/typography/paragraph";
|
||||
import { diff } from "diffblazer";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import {
|
||||
HistorySession,
|
||||
isEncryptedContent,
|
||||
Note,
|
||||
NoteContent,
|
||||
SessionContentItem,
|
||||
TrashOrItem
|
||||
} from "@notesnook/core";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {any} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function NotePreview({ session, content, note }) {
|
||||
export default function NotePreview({
|
||||
session,
|
||||
content,
|
||||
note
|
||||
}: {
|
||||
session: HistorySession & { session: string };
|
||||
content:
|
||||
| Partial<
|
||||
NoteContent<boolean> & {
|
||||
title: string;
|
||||
}
|
||||
>
|
||||
| undefined;
|
||||
note: TrashOrItem<Note>;
|
||||
}) {
|
||||
const { colors } = useThemeColors();
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
async function restore() {
|
||||
if (note && note.type === "trash") {
|
||||
if ((await db.trash.restore(note.id)) === false) return;
|
||||
await db.trash.restore(note.id);
|
||||
Navigation.queueRoutesForUpdate();
|
||||
useSelectionStore.getState().setSelectionMode(false);
|
||||
useSelectionStore.getState().setSelectionMode();
|
||||
ToastManager.show({
|
||||
heading: strings.noteRestored(),
|
||||
type: "success"
|
||||
@@ -91,15 +113,17 @@ export default function NotePreview({ session, content, note }) {
|
||||
negativeText: strings.cancel(),
|
||||
context: "local",
|
||||
positivePress: async () => {
|
||||
await db.trash.delete(note.id);
|
||||
useTrashStore.getState().refresh();
|
||||
useSelectionStore.getState().setSelectionMode(false);
|
||||
ToastManager.show({
|
||||
heading: strings.noteDeleted(),
|
||||
type: "success",
|
||||
context: "local"
|
||||
});
|
||||
eSendEvent(eCloseSheet);
|
||||
if (note) {
|
||||
await db.trash.delete(note.id);
|
||||
useTrashStore.getState().refresh();
|
||||
useSelectionStore.getState().setSelectionMode();
|
||||
ToastManager.show({
|
||||
heading: strings.noteDeleted(),
|
||||
type: "success",
|
||||
context: "local"
|
||||
});
|
||||
eSendEvent(eCloseSheet);
|
||||
}
|
||||
},
|
||||
positiveType: "error"
|
||||
});
|
||||
@@ -113,8 +137,11 @@ export default function NotePreview({ session, content, note }) {
|
||||
}}
|
||||
>
|
||||
<Dialog context="local" />
|
||||
<DialogHeader padding={12} title={note?.title || session?.session} />
|
||||
{!session?.locked && !locked ? (
|
||||
<DialogHeader
|
||||
padding={12}
|
||||
title={content?.title || note.title || session?.session}
|
||||
/>
|
||||
{!session?.locked && !locked && content?.data ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -125,17 +152,27 @@ export default function NotePreview({ session, content, note }) {
|
||||
editorId="historyPreview"
|
||||
onLoad={async (loadContent) => {
|
||||
try {
|
||||
if (content.data) {
|
||||
const _note = note || (await db.notes.note(session?.noteId));
|
||||
const currentContent = await db.content.get(_note.contentId);
|
||||
loadContent({
|
||||
data: diff(currentContent.data, content.data),
|
||||
id: _note.id
|
||||
});
|
||||
if (content?.data) {
|
||||
const currentContent = note?.contentId
|
||||
? await db.content.get(note.contentId)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
currentContent?.data &&
|
||||
!isEncryptedContent(currentContent)
|
||||
) {
|
||||
loadContent({
|
||||
data: diff(
|
||||
currentContent?.data || "<p></p>",
|
||||
content.data as string
|
||||
),
|
||||
id: session?.noteId
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ToastManager.error(
|
||||
e,
|
||||
e as Error,
|
||||
"Failed to load history preview",
|
||||
"local"
|
||||
);
|
||||
@@ -153,7 +190,9 @@ export default function NotePreview({ session, content, note }) {
|
||||
}}
|
||||
>
|
||||
<Paragraph color={colors.secondary.paragraph}>
|
||||
{strings.encryptedNoteHistoryNotice()}
|
||||
{!content?.data
|
||||
? strings.noContent()
|
||||
: strings.encryptedNoteHistoryNotice()}
|
||||
</Paragraph>
|
||||
</View>
|
||||
)}
|
||||
@@ -298,7 +298,7 @@ export const useEditor = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (isContentInvalid(data) && id) {
|
||||
if (!title && isContentInvalid(data) && id) {
|
||||
// Create a new history session if recieved empty or invalid content
|
||||
// To ensure that history is preserved for correct content.
|
||||
currentSessionHistoryId = editorSessionHistory.newSession(id);
|
||||
|
||||
@@ -17,13 +17,7 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Flex, Text, Button } from "@theme-ui/components";
|
||||
import { Copy, Restore } from "../icons";
|
||||
import ContentToggle from "./content-toggle";
|
||||
@@ -59,6 +53,8 @@ function DiffViewer(props: DiffViewerProps) {
|
||||
|
||||
const onResolveContent = useCallback(
|
||||
(saveCopy: boolean) => {
|
||||
if (!conflictedContent) return;
|
||||
|
||||
const toKeep =
|
||||
selectedContent === 1 ? session.content?.conflicted : session.content;
|
||||
const toCopy =
|
||||
@@ -69,7 +65,7 @@ function DiffViewer(props: DiffViewerProps) {
|
||||
toKeep,
|
||||
toCopy: saveCopy ? toCopy : undefined,
|
||||
toKeepDateEdited: toKeep.dateEdited,
|
||||
dateResolved: conflictedContent!.dateModified
|
||||
dateResolved: conflictedContent.dateModified
|
||||
});
|
||||
},
|
||||
[conflictedContent, selectedContent, session.content, session.note]
|
||||
@@ -82,8 +78,6 @@ function DiffViewer(props: DiffViewerProps) {
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
if (!conflictedContent || !content) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={(el) => {
|
||||
@@ -105,11 +99,16 @@ function DiffViewer(props: DiffViewerProps) {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textAlign: "center"
|
||||
textAlign: "center",
|
||||
paddingTop: 10
|
||||
}}
|
||||
>
|
||||
{session.note.title}
|
||||
</Text>
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
session.type === "diff" && session.oldTitle
|
||||
? diff(session.oldTitle || "", session.note.title)
|
||||
: session.note.title
|
||||
}}
|
||||
></Text>
|
||||
<Flex mt={1} sx={{ alignSelf: "center", justifySelf: "center" }}>
|
||||
{session.type === "diff" ? (
|
||||
<>
|
||||
@@ -140,28 +139,34 @@ function DiffViewer(props: DiffViewerProps) {
|
||||
<Restore size={18} />
|
||||
<Text ml={1}>{strings.restoreThisVersion()}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
const { closeTabs, openSession } = useEditorStore.getState();
|
||||
{!content || !conflictedContent ? null : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
const { closeTabs, openSession } = useEditorStore.getState();
|
||||
|
||||
const noteId = await createCopy(session.note, content);
|
||||
const noteId = await createCopy(
|
||||
session.note,
|
||||
content,
|
||||
session.oldTitle
|
||||
);
|
||||
|
||||
closeTabs(session.id);
|
||||
closeTabs(session.id);
|
||||
|
||||
await notesStore.refresh();
|
||||
await openSession(noteId);
|
||||
}}
|
||||
mr={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
display: "flex"
|
||||
}}
|
||||
>
|
||||
<Copy size={18} />
|
||||
<Text ml={1}>{strings.saveACopy()}</Text>
|
||||
</Button>
|
||||
await notesStore.refresh();
|
||||
await openSession(noteId);
|
||||
}}
|
||||
mr={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
display: "flex"
|
||||
}}
|
||||
>
|
||||
<Copy size={18} />
|
||||
<Text ml={1}>{strings.saveACopy()}</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="errorSecondary"
|
||||
onClick={() => {
|
||||
@@ -174,165 +179,183 @@ function DiffViewer(props: DiffViewerProps) {
|
||||
</>
|
||||
) : null}
|
||||
</Flex>
|
||||
<ScrollSync>
|
||||
<Flex
|
||||
{!conflictedContent || !content ? (
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
flex: "1 1 auto",
|
||||
flexDirection: ["column", "column", "row"],
|
||||
overflow: "hidden"
|
||||
px: 2,
|
||||
py: 1,
|
||||
my: 1,
|
||||
borderTop: "1px solid var(--border)"
|
||||
}}
|
||||
>
|
||||
{strings.noContent()}
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollSync>
|
||||
<Flex
|
||||
className="firstEditor"
|
||||
data-test-id="first-editor"
|
||||
sx={{
|
||||
flex: "1 1 auto",
|
||||
flexDirection: "column",
|
||||
width: ["100%", "100%", "50%"],
|
||||
height: ["50%", "50%", "100%"]
|
||||
flexDirection: ["column", "column", "row"],
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
{content.locked ? (
|
||||
<UnlockView
|
||||
title={getFormattedDate(content.dateEdited)}
|
||||
subtitle={strings.enterPasswordToUnlockVersion()}
|
||||
buttonTitle={strings.unlock()}
|
||||
unlock={async (password) => {
|
||||
const decryptedContent = await db.vault.decryptContent(
|
||||
content,
|
||||
password
|
||||
);
|
||||
setContent({
|
||||
...content,
|
||||
...decryptedContent,
|
||||
locked: false
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ContentToggle
|
||||
label={
|
||||
session.type === "diff"
|
||||
? strings.olderVersion()
|
||||
: strings.currentNote()
|
||||
}
|
||||
readonly={session.type === "diff"}
|
||||
dateEdited={content.dateEdited}
|
||||
isSelected={selectedContent === 0}
|
||||
isOtherSelected={selectedContent === 1}
|
||||
onToggle={() => setSelectedContent((s) => (s === 0 ? -1 : 0))}
|
||||
resolveConflict={onResolveContent}
|
||||
sx={{
|
||||
borderStyle: "solid",
|
||||
borderWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "border",
|
||||
px: 2,
|
||||
pb: 1
|
||||
<Flex
|
||||
className="firstEditor"
|
||||
data-test-id="first-editor"
|
||||
sx={{
|
||||
flex: "1 1 auto",
|
||||
flexDirection: "column",
|
||||
width: ["100%", "100%", "50%"],
|
||||
height: ["50%", "50%", "100%"]
|
||||
}}
|
||||
>
|
||||
{content.locked ? (
|
||||
<UnlockView
|
||||
title={getFormattedDate(content.dateEdited)}
|
||||
subtitle={strings.enterPasswordToUnlockVersion()}
|
||||
buttonTitle={strings.unlock()}
|
||||
unlock={async (password) => {
|
||||
const decryptedContent = await db.vault.decryptContent(
|
||||
content,
|
||||
password
|
||||
);
|
||||
setContent({
|
||||
...content,
|
||||
...decryptedContent,
|
||||
locked: false
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollSyncPane>
|
||||
<Flex
|
||||
) : (
|
||||
<>
|
||||
<ContentToggle
|
||||
label={
|
||||
session.type === "diff"
|
||||
? strings.olderVersion()
|
||||
: strings.currentNote()
|
||||
}
|
||||
readonly={session.type === "diff"}
|
||||
dateEdited={content.dateEdited}
|
||||
isSelected={selectedContent === 0}
|
||||
isOtherSelected={selectedContent === 1}
|
||||
onToggle={() =>
|
||||
setSelectedContent((s) => (s === 0 ? -1 : 0))
|
||||
}
|
||||
resolveConflict={onResolveContent}
|
||||
sx={{
|
||||
px: 2,
|
||||
overflowY: "auto",
|
||||
flex: 1,
|
||||
borderStyle: "solid",
|
||||
borderWidth: 0,
|
||||
borderRightWidth: [0, 0, 1],
|
||||
borderBottomWidth: [1, 1, 0],
|
||||
borderColor: "border"
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "border",
|
||||
px: 2,
|
||||
pb: 1
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
id={content.id}
|
||||
content={() => content.data}
|
||||
session={session}
|
||||
nonce={content.dateEdited}
|
||||
options={{ readonly: true, headless: true }}
|
||||
/>
|
||||
</Flex>
|
||||
</ScrollSyncPane>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex
|
||||
className="secondEditor"
|
||||
data-test-id="second-editor"
|
||||
sx={{
|
||||
flex: "1 1 auto",
|
||||
flexDirection: "column",
|
||||
width: ["100%", "100%", "50%"],
|
||||
height: ["50%", "50%", "100%"],
|
||||
borderLeft: conflictedContent.locked
|
||||
? "1px solid var(--border)"
|
||||
: "none"
|
||||
}}
|
||||
>
|
||||
{conflictedContent.locked ? (
|
||||
<UnlockView
|
||||
title={getFormattedDate(conflictedContent.dateEdited)}
|
||||
subtitle={strings.enterPasswordToUnlockVersion()}
|
||||
buttonTitle={strings.unlock()}
|
||||
unlock={async (password) => {
|
||||
const decryptedContent = await db.vault.decryptContent(
|
||||
conflictedContent,
|
||||
password
|
||||
);
|
||||
setConflictedContent({
|
||||
...conflictedContent,
|
||||
...decryptedContent,
|
||||
locked: false
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ContentToggle
|
||||
readonly={session.type === "diff"}
|
||||
resolveConflict={onResolveContent}
|
||||
label={
|
||||
session.type === "diff"
|
||||
? strings.currentNote()
|
||||
: strings.incomingNote()
|
||||
}
|
||||
isSelected={selectedContent === 1}
|
||||
isOtherSelected={selectedContent === 0}
|
||||
dateEdited={conflictedContent.dateEdited}
|
||||
onToggle={() => setSelectedContent((s) => (s === 1 ? -1 : 1))}
|
||||
sx={{
|
||||
alignItems: "flex-end",
|
||||
borderStyle: "solid",
|
||||
borderWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "border",
|
||||
px: 2,
|
||||
pb: 1,
|
||||
pt: [1, 1, 0]
|
||||
/>
|
||||
|
||||
<ScrollSyncPane>
|
||||
<Flex
|
||||
sx={{
|
||||
px: 2,
|
||||
overflowY: "auto",
|
||||
flex: 1,
|
||||
borderStyle: "solid",
|
||||
borderWidth: 0,
|
||||
borderRightWidth: [0, 0, 1],
|
||||
borderBottomWidth: [1, 1, 0],
|
||||
borderColor: "border"
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
id={content.id}
|
||||
content={() => content.data}
|
||||
session={session}
|
||||
nonce={content.dateEdited}
|
||||
options={{ readonly: true, headless: true }}
|
||||
/>
|
||||
</Flex>
|
||||
</ScrollSyncPane>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex
|
||||
className="secondEditor"
|
||||
data-test-id="second-editor"
|
||||
sx={{
|
||||
flex: "1 1 auto",
|
||||
flexDirection: "column",
|
||||
width: ["100%", "100%", "50%"],
|
||||
height: ["50%", "50%", "100%"],
|
||||
borderLeft: conflictedContent?.locked
|
||||
? "1px solid var(--border)"
|
||||
: "none"
|
||||
}}
|
||||
>
|
||||
{conflictedContent.locked ? (
|
||||
<UnlockView
|
||||
title={getFormattedDate(conflictedContent.dateEdited)}
|
||||
subtitle={strings.enterPasswordToUnlockVersion()}
|
||||
buttonTitle={strings.unlock()}
|
||||
unlock={async (password) => {
|
||||
const decryptedContent = await db.vault.decryptContent(
|
||||
conflictedContent,
|
||||
password
|
||||
);
|
||||
setConflictedContent({
|
||||
...conflictedContent,
|
||||
...decryptedContent,
|
||||
locked: false
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ScrollSyncPane>
|
||||
<Flex sx={{ px: 2, overflow: "auto" }}>
|
||||
<Editor
|
||||
id={`${conflictedContent.id}-conflicted`}
|
||||
session={session}
|
||||
content={() =>
|
||||
content.locked
|
||||
? conflictedContent.data
|
||||
: diff(content.data, conflictedContent.data)
|
||||
}
|
||||
nonce={conflictedContent.dateEdited}
|
||||
options={{ readonly: true, headless: true }}
|
||||
/>
|
||||
</Flex>
|
||||
</ScrollSyncPane>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<ContentToggle
|
||||
readonly={session.type === "diff"}
|
||||
resolveConflict={onResolveContent}
|
||||
label={
|
||||
session.type === "diff"
|
||||
? strings.currentNote()
|
||||
: strings.incomingNote()
|
||||
}
|
||||
isSelected={selectedContent === 1}
|
||||
isOtherSelected={selectedContent === 0}
|
||||
dateEdited={conflictedContent.dateEdited}
|
||||
onToggle={() =>
|
||||
setSelectedContent((s) => (s === 1 ? -1 : 1))
|
||||
}
|
||||
sx={{
|
||||
alignItems: "flex-end",
|
||||
borderStyle: "solid",
|
||||
borderWidth: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "border",
|
||||
px: 2,
|
||||
pb: 1,
|
||||
pt: [1, 1, 0]
|
||||
}}
|
||||
/>
|
||||
<ScrollSyncPane>
|
||||
<Flex sx={{ px: 2, overflow: "auto" }}>
|
||||
<Editor
|
||||
id={`${conflictedContent.id}-conflicted`}
|
||||
session={session}
|
||||
content={() =>
|
||||
content.locked
|
||||
? conflictedContent.data
|
||||
: diff(content.data, conflictedContent.data)
|
||||
}
|
||||
nonce={conflictedContent.dateEdited}
|
||||
options={{ readonly: true, headless: true }}
|
||||
/>
|
||||
</Flex>
|
||||
</ScrollSyncPane>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ScrollSync>
|
||||
</ScrollSync>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -374,7 +397,7 @@ async function resolveConflict({
|
||||
await notesStore.refresh();
|
||||
}
|
||||
|
||||
async function createCopy(note: Note, content: ContentItem) {
|
||||
async function createCopy(note: Note, content: ContentItem, title?: string) {
|
||||
if (content.locked) {
|
||||
const contentId = await db.content.add({
|
||||
locked: true,
|
||||
@@ -384,7 +407,7 @@ async function createCopy(note: Note, content: ContentItem) {
|
||||
});
|
||||
return await db.notes.add({
|
||||
contentId,
|
||||
title: note.title + " (COPY)"
|
||||
title: title || note.title + " (COPY)"
|
||||
});
|
||||
} else {
|
||||
return await db.notes.add({
|
||||
@@ -392,7 +415,7 @@ async function createCopy(note: Note, content: ContentItem) {
|
||||
type: "tiptap",
|
||||
data: content.data
|
||||
},
|
||||
title: note.title + " (COPY)"
|
||||
title: title || note.title + " (COPY)"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ export type DiffEditorSession = BaseEditorSession & {
|
||||
type: "diff";
|
||||
note: Note;
|
||||
content: ContentItem;
|
||||
oldTitle?: string;
|
||||
historySessionId: string;
|
||||
};
|
||||
|
||||
@@ -619,12 +620,13 @@ class EditorStore extends BaseStore<EditorStore> {
|
||||
openDiffSession = async (noteId: string, sessionId: string) => {
|
||||
const session = await db.noteHistory.session(sessionId);
|
||||
const note = await db.notes.note(noteId);
|
||||
if (!session || !note || !note.contentId) return;
|
||||
if (!session || !note) return;
|
||||
|
||||
const currentContent = await db.content.get(note.contentId);
|
||||
const currentContent = note.contentId
|
||||
? await db.content.get(note.contentId)
|
||||
: undefined;
|
||||
const oldContent = await db.noteHistory.content(session.id);
|
||||
|
||||
if (!oldContent || !currentContent) return;
|
||||
if (!oldContent) return;
|
||||
|
||||
const {
|
||||
getSession,
|
||||
@@ -658,9 +660,10 @@ class EditorStore extends BaseStore<EditorStore> {
|
||||
note,
|
||||
tabId,
|
||||
title: label,
|
||||
oldTitle: oldContent.title,
|
||||
historySessionId: session.id,
|
||||
content: {
|
||||
type: oldContent.type,
|
||||
type: oldContent.type || "tiptap",
|
||||
dateCreated: session.dateCreated,
|
||||
dateEdited: session.dateModified,
|
||||
dateModified: session.dateModified,
|
||||
@@ -670,7 +673,7 @@ class EditorStore extends BaseStore<EditorStore> {
|
||||
conflicted: currentContent,
|
||||
...(isCipher(oldContent.data)
|
||||
? { locked: true, data: oldContent.data }
|
||||
: { locked: false, data: oldContent.data })
|
||||
: { locked: false, data: oldContent.data || "" })
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1354,7 +1357,7 @@ const useEditorStore = createPersistedStore(EditorStore, {
|
||||
}, [] as EditorSession[])
|
||||
}),
|
||||
storage: db.config() as PersistStorage<Partial<EditorStore>>
|
||||
});
|
||||
}) as ReturnType<typeof createPersistedStore<EditorStore>>;
|
||||
export { useEditorStore, SESSION_STATES };
|
||||
|
||||
const MILLISECONDS_IN_A_MINUTE = 60 * 1000;
|
||||
|
||||
@@ -199,3 +199,162 @@ test("locking an old note should clear its history", () =>
|
||||
expect(await db.noteHistory.get(id).count()).toBe(0);
|
||||
}
|
||||
));
|
||||
|
||||
test("note history item can be created by setting note title", () =>
|
||||
noteTest({ title: "Test note", sessionId: "notesession" }).then(
|
||||
async ({ db, id }) => {
|
||||
expect(await db.noteHistory.get(id).count()).toBe(1);
|
||||
const history = await db.noteHistory.get(id);
|
||||
const items = await history.items();
|
||||
const content = await db.noteHistory.sessionContent.get(
|
||||
items[0].sessionContentId
|
||||
);
|
||||
expect(content.title).toBe("Test note");
|
||||
}
|
||||
));
|
||||
|
||||
test("note history item can be created by setting note title and content both", () =>
|
||||
noteTest({
|
||||
...TEST_NOTE,
|
||||
title: "Test note",
|
||||
sessionId: "notesession"
|
||||
}).then(async ({ db, id }) => {
|
||||
expect(await db.noteHistory.get(id).count()).toBe(1);
|
||||
const history = db.noteHistory.get(id);
|
||||
const items = await history.items();
|
||||
let content = await db.noteHistory.sessionContent.get(
|
||||
items[0].sessionContentId
|
||||
);
|
||||
expect(await db.noteHistory.collection.count()).toBe(1);
|
||||
expect(content.data).toBe(TEST_NOTE.content.data);
|
||||
expect(content.title).toBe("Test note");
|
||||
await db.notes.add({
|
||||
id: id,
|
||||
content: TEST_NOTE.content,
|
||||
sessionId: "notesession"
|
||||
});
|
||||
|
||||
expect(await db.noteHistory.collection.count()).toBe(1);
|
||||
content = await db.noteHistory.sessionContent.get(
|
||||
items[0].sessionContentId
|
||||
);
|
||||
|
||||
expect(content.data).toBe(TEST_NOTE.content.data);
|
||||
expect(content.title).toBe("Test note");
|
||||
await db.notes.add({
|
||||
id: id,
|
||||
title: "Test note",
|
||||
sessionId: "notesession"
|
||||
});
|
||||
|
||||
expect(await db.noteHistory.collection.count()).toBe(1);
|
||||
content = await db.noteHistory.sessionContent.get(
|
||||
items[0].sessionContentId
|
||||
);
|
||||
|
||||
expect(content.data).toBe(TEST_NOTE.content.data);
|
||||
expect(content.title).toBe("Test note");
|
||||
}));
|
||||
|
||||
test("restoring an old session should replace note's content title", () =>
|
||||
noteTest({ title: "Test note", sessionId: Date.now() }).then(
|
||||
async ({ db, id }) => {
|
||||
await delay(1000);
|
||||
let newTitle = "Test note (edited)";
|
||||
|
||||
const sessionId = `${Date.now() + 10000}`;
|
||||
await db.notes.add({
|
||||
id: id,
|
||||
title: newTitle,
|
||||
sessionId
|
||||
});
|
||||
|
||||
const [, firstVersion] = await db.noteHistory
|
||||
.get(id)
|
||||
.items(undefined, { sortBy: "dateModified", sortDirection: "desc" });
|
||||
expect(firstVersion.id).not.toBe(`${id}_${sessionId}`);
|
||||
await db.noteHistory.restore(firstVersion.id);
|
||||
|
||||
const title = (await db.notes.note(id)).title;
|
||||
expect(title).toBe("Test note");
|
||||
}
|
||||
));
|
||||
|
||||
test("restoring an old session should replace note's content and title", () =>
|
||||
noteTest({ ...TEST_NOTE, title: "Test note", sessionId: Date.now() }).then(
|
||||
async ({ db, id }) => {
|
||||
await delay(1000);
|
||||
let newTitle = "Test note (edited)";
|
||||
let editedContent = {
|
||||
data: TEST_NOTE.content.data + "<p>Some new content</p>",
|
||||
type: "tiptap"
|
||||
};
|
||||
|
||||
const sessionId = `${Date.now() + 10000}`;
|
||||
await db.notes.add({
|
||||
id: id,
|
||||
title: newTitle,
|
||||
sessionId,
|
||||
content: editedContent
|
||||
});
|
||||
|
||||
const [, firstVersion] = await db.noteHistory
|
||||
.get(id)
|
||||
.items(undefined, { sortBy: "dateModified", sortDirection: "desc" });
|
||||
expect(firstVersion.id).not.toBe(`${id}_${sessionId}`);
|
||||
await db.noteHistory.restore(firstVersion.id);
|
||||
|
||||
const title = (await db.notes.note(id)).title;
|
||||
expect(title).toBe("Test note");
|
||||
|
||||
const contentId = (await db.notes.note(id)).contentId;
|
||||
expect((await db.content.get(contentId)).data).toBe(
|
||||
TEST_NOTE.content.data
|
||||
);
|
||||
}
|
||||
));
|
||||
|
||||
test.only("note history item has consistent note and title data between sessions", () =>
|
||||
noteTest({
|
||||
...TEST_NOTE,
|
||||
title: "Test note",
|
||||
sessionId: "notesession1"
|
||||
}).then(async ({ db, id }) => {
|
||||
expect(await db.noteHistory.get(id).count()).toBe(1);
|
||||
const history = db.noteHistory.get(id);
|
||||
let items = await history.items();
|
||||
let content = await db.noteHistory.sessionContent.get(
|
||||
items[0].sessionContentId
|
||||
);
|
||||
expect(await db.noteHistory.collection.count()).toBe(1);
|
||||
expect(content.data).toBe(TEST_NOTE.content.data);
|
||||
expect(content.title).toBe("Test note");
|
||||
|
||||
await db.notes.add({
|
||||
id: id,
|
||||
content: TEST_NOTE.content,
|
||||
sessionId: "notesession2"
|
||||
});
|
||||
|
||||
items = await history.items();
|
||||
content = await db.noteHistory.sessionContent.get(
|
||||
items[1].sessionContentId
|
||||
);
|
||||
|
||||
expect(content.data).toBe(TEST_NOTE.content.data);
|
||||
expect(content.title).toBe("Test note");
|
||||
|
||||
await db.notes.add({
|
||||
id: id,
|
||||
title: "Test note updated",
|
||||
sessionId: "notesession3"
|
||||
});
|
||||
|
||||
items = await history.items();
|
||||
content = await db.noteHistory.sessionContent.get(
|
||||
items[2].sessionContentId
|
||||
);
|
||||
|
||||
expect(content.data).toBe(TEST_NOTE.content.data);
|
||||
expect(content.title).toBe("Test note updated");
|
||||
}));
|
||||
|
||||
@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import Database from "../api/index.js";
|
||||
import { isCipher } from "../utils/crypto.js";
|
||||
import { FilteredSelector, SQLCollection } from "../database/sql-collection.js";
|
||||
import { HistorySession, isDeleted, NoteContent } from "../types.js";
|
||||
import { HistorySession, isDeleted, Note, NoteContent } from "../types.js";
|
||||
import { makeSessionContentId } from "../utils/id.js";
|
||||
import { ICollection } from "./collection.js";
|
||||
import { SessionContent } from "./session-content.js";
|
||||
@@ -74,13 +74,19 @@ export class NoteHistory implements ICollection {
|
||||
|
||||
async add(
|
||||
sessionId: string,
|
||||
content: NoteContent<boolean> & { noteId: string; locked: boolean }
|
||||
content: Partial<NoteContent<boolean>> & {
|
||||
noteId: string;
|
||||
locked?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
) {
|
||||
const { noteId, locked } = content;
|
||||
sessionId = `${noteId}_${sessionId}`;
|
||||
|
||||
if (await this.collection.exists(sessionId)) {
|
||||
await this.collection.update([sessionId], { locked });
|
||||
await this.collection.update([sessionId], {
|
||||
locked
|
||||
});
|
||||
} else {
|
||||
await this.collection.upsert({
|
||||
type: "session",
|
||||
@@ -93,7 +99,9 @@ export class NoteHistory implements ICollection {
|
||||
locked
|
||||
});
|
||||
}
|
||||
|
||||
await this.sessionContent.add(sessionId, content, locked);
|
||||
|
||||
await this.cleanup(noteId);
|
||||
|
||||
return sessionId;
|
||||
@@ -182,22 +190,46 @@ export class NoteHistory implements ICollection {
|
||||
if (!note || !content) return;
|
||||
|
||||
if (session.locked && isCipher(content.data)) {
|
||||
const sessionId = `${Date.now()}`;
|
||||
await this.db.content.add({
|
||||
id: note.contentId,
|
||||
noteId: session.noteId,
|
||||
sessionId: `${Date.now()}`,
|
||||
sessionId: sessionId,
|
||||
data: content.data,
|
||||
type: content.type
|
||||
});
|
||||
} else if (content.data && !isCipher(content.data)) {
|
||||
await this.db.notes.add({
|
||||
id: session.noteId,
|
||||
sessionId: `${Date.now()}`,
|
||||
content: {
|
||||
data: content.data,
|
||||
type: content.type
|
||||
|
||||
if (content.title) {
|
||||
await this.db.notes.add({
|
||||
id: session.noteId,
|
||||
sessionId: sessionId,
|
||||
title: content.title
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const note: Partial<
|
||||
Note & {
|
||||
content: NoteContent<false>;
|
||||
sessionId: string;
|
||||
}
|
||||
});
|
||||
> = {
|
||||
id: session.noteId,
|
||||
sessionId: `${Date.now()}`
|
||||
};
|
||||
|
||||
note.content =
|
||||
content.data && content.type && !isCipher(content.data)
|
||||
? {
|
||||
data: content.data,
|
||||
type: content.type
|
||||
}
|
||||
: { data: "<p></p>", type: "tiptap" };
|
||||
|
||||
if (content.title) {
|
||||
note.title = content.title;
|
||||
}
|
||||
|
||||
await this.db.notes.add(note);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,13 @@ export class Notes implements ICollection {
|
||||
});
|
||||
this.totalNotes++;
|
||||
}
|
||||
|
||||
if (item.sessionId && typeof item.title === "string") {
|
||||
await this.db.noteHistory.add(item.sessionId, {
|
||||
title: item.title,
|
||||
noteId: id
|
||||
});
|
||||
}
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -44,30 +44,63 @@ export class SessionContent implements ICollection {
|
||||
|
||||
async add<TLocked extends boolean>(
|
||||
sessionId: string,
|
||||
content: NoteContent<TLocked>,
|
||||
locked: TLocked
|
||||
content: Partial<NoteContent<TLocked>> & { title?: string; noteId: string },
|
||||
locked?: TLocked
|
||||
) {
|
||||
if (!sessionId || !content) return;
|
||||
// const data =
|
||||
// locked || isCipher(content.data)
|
||||
// ? content.data
|
||||
// : await this.db.compressor().compress(content.data);
|
||||
await this.collection.upsert({
|
||||
const sessionContentItemId = makeSessionContentId(sessionId);
|
||||
const sessionContentExists = await this.collection.exists(
|
||||
sessionContentItemId
|
||||
);
|
||||
const sessionItem: Partial<SessionContentItem> = {
|
||||
type: "sessioncontent",
|
||||
id: makeSessionContentId(sessionId),
|
||||
data: content.data,
|
||||
contentType: content.type,
|
||||
id: sessionContentItemId,
|
||||
compressed: false,
|
||||
localOnly: true,
|
||||
locked,
|
||||
locked: locked || false,
|
||||
dateCreated: Date.now(),
|
||||
dateModified: Date.now()
|
||||
});
|
||||
};
|
||||
|
||||
if (content.data && content.type) {
|
||||
sessionItem.data = content.data;
|
||||
sessionItem.contentType = content.type;
|
||||
|
||||
if (typeof content.title !== "string" && !sessionContentExists) {
|
||||
const note = await this.db.notes.note(content.noteId);
|
||||
sessionItem.title = note?.title;
|
||||
}
|
||||
}
|
||||
|
||||
if (content.title) {
|
||||
sessionItem.title = content.title;
|
||||
|
||||
if (!content.data && !content.type && !sessionContentExists) {
|
||||
const note = await this.db.notes.note(content.noteId);
|
||||
if (note?.contentId) {
|
||||
const noteContent = await this.db.content.get(note?.contentId);
|
||||
if (noteContent) {
|
||||
sessionItem.data = noteContent?.data;
|
||||
sessionItem.contentType = noteContent?.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionContentExists) {
|
||||
this.collection.update([sessionContentItemId], sessionItem);
|
||||
} else {
|
||||
await this.collection.upsert(sessionItem as SessionContentItem);
|
||||
}
|
||||
}
|
||||
|
||||
async get(
|
||||
sessionContentId: string
|
||||
): Promise<NoteContent<boolean> | undefined> {
|
||||
): Promise<Partial<NoteContent<boolean> & { title: string }> | undefined> {
|
||||
const session = await this.collection.get(sessionContentId);
|
||||
if (!session || isDeleted(session)) return;
|
||||
|
||||
@@ -90,7 +123,8 @@ export class SessionContent implements ICollection {
|
||||
session.compressed && !isCipher(session.data)
|
||||
? await compressor.decompress(session.data)
|
||||
: session.data,
|
||||
type: session.contentType
|
||||
type: session.contentType,
|
||||
title: session.title
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -414,7 +414,14 @@ export class NNMigrationProvider implements MigrationProvider {
|
||||
await db.schema
|
||||
.createIndex("note_expiry_date")
|
||||
.on("notes")
|
||||
.expression(sql`expiryDate ->> '$.value'`)
|
||||
.expression(sql`expiryDate ->> '$.value'`);
|
||||
}
|
||||
},
|
||||
"a-2026-01-09": {
|
||||
async up(db) {
|
||||
await db.schema
|
||||
.alterTable("sessioncontent")
|
||||
.addColumn("title", "text")
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +433,7 @@ export interface SessionContentItem extends BaseItem<"sessioncontent"> {
|
||||
compressed: boolean;
|
||||
localOnly: boolean;
|
||||
locked: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type TrashCleanupInterval = 1 | 7 | 30 | 365 | -1;
|
||||
|
||||
@@ -6552,6 +6552,10 @@ msgstr "This may take a while"
|
||||
msgid "This must only be used for troubleshooting. Using it regularly for sync is not recommended and will lead to unexpected data loss and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co."
|
||||
msgstr "This must only be used for troubleshooting. Using it regularly for sync is not recommended and will lead to unexpected data loss and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co."
|
||||
|
||||
#: src/strings.ts:2635
|
||||
msgid "This note is empty"
|
||||
msgstr "This note is empty"
|
||||
|
||||
#: src/strings.ts:1594
|
||||
msgid "This note is locked"
|
||||
msgstr "This note is locked"
|
||||
|
||||
@@ -6511,6 +6511,10 @@ msgstr ""
|
||||
msgid "This must only be used for troubleshooting. Using it regularly for sync is not recommended and will lead to unexpected data loss and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2635
|
||||
msgid "This note is empty"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1594
|
||||
msgid "This note is locked"
|
||||
msgstr ""
|
||||
|
||||
@@ -2631,5 +2631,6 @@ Use this if changes from other devices are not appearing on this device. This wi
|
||||
unsetExpiry: () => t`Unset expiry`,
|
||||
expiryDate: () => t`Expiry date`,
|
||||
exportCsv: () => t`Export CSV`,
|
||||
importCsv: () => t`Import CSV`
|
||||
importCsv: () => t`Import CSV`,
|
||||
noContent: () => t`This note is empty`
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user