文件
mynode/packages/lib/commands/convertNoteToMarkdown.test.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

344 行
15 KiB
TypeScript

import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
import { defaultState, State } from '../reducer';
import Note from '../models/Note';
import { MarkupLanguage } from '@joplin/renderer';
import { db, encryptionService, setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import Folder from '../models/Folder';
import { NoteEntity } from '../services/database/types';
import shim from '../shim';
import SearchEngine from '../services/search/SearchEngine';
import SearchEngineUtils from '../services/search/SearchEngineUtils';
import { getTrashFolderId } from '../services/trash';
import Setting from '../models/Setting';
import EncryptionService from '../services/e2ee/EncryptionService';
import NoteLockKey from '../services/noteLock/NoteLockKey';
import NoteLockService from '../services/noteLock/NoteLockService';
import NoteLockSession from '../services/noteLock/NoteLockSession';
describe('convertNoteToMarkdown', () => {
let state: State = undefined;
beforeEach(async () => {
state = defaultState;
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
shim.showToast = jest.fn();
});
it('should keep the original note active and trash an HTML backup', async () => {
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
const refreshedNote = await Note.load(htmlNote.id);
const trashedNotes = await Note.previews(getTrashFolderId());
expect(refreshedNote.id).toBe(htmlNote.id);
expect(refreshedNote.deleted_time).toBe(0);
expect(refreshedNote.markup_language).toBe(MarkupLanguage.Markdown);
expect(trashedNotes).toHaveLength(1);
const backupNote = await Note.load(trashedNotes[0].id);
expect(backupNote.id).not.toBe(htmlNote.id);
expect(backupNote.deleted_time).not.toBe(0);
expect(backupNote.body).toBe(htmlNote.body);
expect(backupNote.markup_language).toBe(MarkupLanguage.Html);
});
it('should preserve an existing internal link after converting an HTML note', async () => {
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'Target', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
const linkNote = await Note.save({ title: 'Link', body: `[Target](:/${htmlNote.id})`, parent_id: folder.id });
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
const savedLinkNote = await Note.load(linkNote.id);
expect(savedLinkNote.body).toBe(linkNote.body);
const linkedIds = await Note.linkedNoteIds(savedLinkNote.body);
expect(linkedIds).toEqual([htmlNote.id]);
const target = await Note.load(linkedIds[0]);
expect(target.deleted_time).toBe(0);
expect(target.markup_language).toBe(MarkupLanguage.Markdown);
expect(target.body).toBe('Hello');
});
it('should preserve note metadata when converting in place', async () => {
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
author: 'test-author',
is_todo: 1,
todo_completed: 1,
};
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
const notes = await Note.previews(folder.id);
expect(notes).toHaveLength(1);
const noteConvertedToMarkdownId = notes[0].id;
const markdownNote = await Note.load(noteConvertedToMarkdownId);
const fields: (keyof NoteEntity)[] = ['parent_id', 'title', 'author', 'is_todo', 'todo_completed'];
for (const field of fields) {
expect(htmlNote[field]).toEqual(markdownNote[field]);
}
});
it('should preserve user timestamps from the original note', async () => {
const folder = await Folder.save({ title: 'test_folder' });
const createdTime = new Date('2026-05-04T10:59:00Z').getTime();
const updatedTime = new Date('2026-05-04T10:59:00Z').getTime();
const userCreatedTime = new Date('2019-07-15T10:02:00Z').getTime();
const userUpdatedTime = new Date('2020-08-16T11:03:00Z').getTime();
const htmlNote = await Note.save({
title: 'test',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
created_time: createdTime,
updated_time: updatedTime,
user_created_time: userCreatedTime,
user_updated_time: userUpdatedTime,
}, { autoTimestamp: false });
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
const notes = await Note.previews(folder.id);
expect(notes).toHaveLength(1);
const markdownNote = await Note.load(notes[0].id);
expect(markdownNote.user_created_time).toBe(userCreatedTime);
expect(markdownNote.user_updated_time).toBe(userUpdatedTime);
expect(markdownNote.updated_time).toBeGreaterThan(updatedTime);
});
it('should generate action to trigger notification', async () => {
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
author: 'test-author',
is_todo: 1,
todo_completed: 1,
};
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(shim.showToast).toHaveBeenCalled();
});
it('should cause note to not disappear from search results', async () => {
const searchEngine = new SearchEngine();
searchEngine.setDb(db());
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'search note', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
await searchEngine.syncTables();
const searchResultsBeforeConversion = await SearchEngineUtils.notesForQuery('search note', true, null, searchEngine);
expect(searchResultsBeforeConversion.notes.map(note => note.id)).toEqual([htmlNote.id]);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
await searchEngine.syncTables();
const searchResultsAfterConversion = await SearchEngineUtils.notesForQuery('search note', true, null, searchEngine);
expect(searchResultsAfterConversion.notes.map(note => note.id)).toEqual([htmlNote.id]);
});
it.each([
{ label: 'not convert a locked note', flagEnabled: true, blocked: true },
{ label: 'convert a locked note when note lock is disabled', flagEnabled: false, blocked: false },
])('should $label', async ({ flagEnabled, blocked }) => {
Setting.setValue('featureFlag.noteLock', flagEnabled);
shim.showErrorDialog = jest.fn();
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html, is_locked: flagEnabled ? 0 : 1 });
if (flagEnabled) {
// An ungated plaintext save now auto-encrypts, so the locked row is created through
// the gate while unlocked, and the session locks afterwards.
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const lockedNote = { ...(await Note.load(htmlNote.id)), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
NoteLockSession.instance().lock();
}
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(shim.showErrorDialog).toHaveBeenCalledTimes(blocked ? 1 : 0);
const originalNote = await Note.load(htmlNote.id);
expect(originalNote.deleted_time).toBe(0);
expect(originalNote.markup_language).toBe(blocked ? MarkupLanguage.Html : MarkupLanguage.Markdown);
});
it('should convert a note that is not locked when note lock is enabled', async () => {
Setting.setValue('featureFlag.noteLock', true);
shim.showErrorDialog = jest.fn();
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(shim.showErrorDialog).not.toHaveBeenCalled();
const notes = await Note.previews(folder.id);
expect(notes).toHaveLength(1);
const converted = await Note.load(notes[0].id);
expect(converted.markup_language).toBe(MarkupLanguage.Markdown);
expect(converted.body).toBe('Hello');
});
it('should not convert any of the selected notes when one is locked and the session is locked', async () => {
Setting.setValue('featureFlag.noteLock', true);
shim.showErrorDialog = jest.fn();
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const folder = await Folder.save({ title: 'test_folder' });
const plainNote = await Note.save({ title: 'plain', body: '<p>plain</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
const lockedSeed = await Note.save({ title: 'locked', body: '<p>locked</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
const lockedNote = { ...(await Note.load(lockedSeed.id)), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
NoteLockSession.instance().lock();
state.selectedNoteIds = [plainNote.id, lockedSeed.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(shim.showErrorDialog).toHaveBeenCalledTimes(1);
expect((await Note.load(plainNote.id)).deleted_time).toBe(0);
expect((await Note.load(lockedNote.id)).deleted_time).toBe(0);
});
it('should convert a locked note when the session is unlocked', async () => {
Setting.setValue('featureFlag.noteLock', true);
shim.showErrorDialog = jest.fn();
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
const lockedNote = { ...(await Note.load(htmlNote.id)), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(shim.showErrorDialog).not.toHaveBeenCalled();
const notes = await Note.previews(folder.id);
expect(notes).toHaveLength(1);
const converted = await Note.load(notes[0].id, { useNoteLock: true });
expect(converted.is_locked).toBe(1);
expect(converted.markup_language).toBe(MarkupLanguage.Markdown);
expect(converted.body).toContain('Hello');
// The stored row keeps a ciphertext body.
expect((await Note.load(notes[0].id)).body).not.toContain('Hello');
expect(converted.id).toBe(htmlNote.id);
expect(converted.deleted_time).toBe(0);
});
it('should not save a decrypted locked note when note lock is turned off during the conversion', async () => {
Setting.setValue('featureFlag.noteLock', true);
shim.showErrorDialog = jest.fn();
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
const lockedNote = { ...(await Note.load(htmlNote.id)), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
state.selectedNoteIds = [htmlNote.id];
// The backup copy is made between the gated load and the save.
const duplicate = Note.duplicate.bind(Note);
const spy = jest.spyOn(Note, 'duplicate').mockImplementation((...args) => {
Setting.setValue('featureFlag.noteLock', false);
return duplicate(...args);
});
try {
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
} finally {
spy.mockRestore();
}
expect(shim.showErrorDialog).toHaveBeenCalledTimes(1);
expect((await Note.load(htmlNote.id)).body).not.toContain('Hello');
});
it('should finish converting the remaining locked notes when the session locks mid-run', async () => {
Setting.setValue('featureFlag.noteLock', true);
shim.showErrorDialog = jest.fn();
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const folder = await Folder.save({ title: 'test_folder' });
const noteIds = [];
for (const title of ['one', 'two']) {
const htmlNote = await Note.save({ title, body: `<p>${title}</p>`, parent_id: folder.id, markup_language: MarkupLanguage.Html });
const lockedNote = { ...(await Note.load(htmlNote.id)), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
noteIds.push(htmlNote.id);
}
state.selectedNoteIds = noteIds;
const originalSave = Note.save.bind(Note);
const spy = jest.spyOn(Note, 'save').mockImplementation(async (note, options) => {
const saved = await originalSave(note, options);
NoteLockSession.instance().lock();
return saved;
});
try {
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
} finally {
spy.mockRestore();
}
expect(shim.showErrorDialog).not.toHaveBeenCalled();
await NoteLockSession.instance().unlock('123456');
const notes = await Note.previews(folder.id);
expect(notes).toHaveLength(2);
for (const preview of notes) {
const converted = await Note.load(preview.id, { useNoteLock: true });
expect(converted.markup_language).toBe(MarkupLanguage.Markdown);
expect(converted.is_locked).toBe(1);
}
});
});