Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ packages/app-cli/app/cli-utils.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.test.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-clear.js
packages/app-cli/app/command-config.js
Expand All @@ -91,6 +92,7 @@ packages/app-cli/app/command-done.test.js
packages/app-cli/app/command-done.js
packages/app-cli/app/command-dump.js
packages/app-cli/app/command-e2ee.js
packages/app-cli/app/command-edit.test.js
packages/app-cli/app/command-edit.js
packages/app-cli/app/command-exit.js
packages/app-cli/app/command-export-sync-status.js
Expand All @@ -115,6 +117,7 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-search.js
packages/app-cli/app/command-server.js
packages/app-cli/app/command-set.test.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
Expand Down Expand Up @@ -1843,6 +1846,7 @@ packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.test.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
Expand Down
4 changes: 4 additions & 0 deletions .ignore.eslint
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ packages/app-cli/app/cli-utils.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.test.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-clear.js
packages/app-cli/app/command-config.js
Expand All @@ -117,6 +118,7 @@ packages/app-cli/app/command-done.test.js
packages/app-cli/app/command-done.js
packages/app-cli/app/command-dump.js
packages/app-cli/app/command-e2ee.js
packages/app-cli/app/command-edit.test.js
packages/app-cli/app/command-edit.js
packages/app-cli/app/command-exit.js
packages/app-cli/app/command-export-sync-status.js
Expand All @@ -141,6 +143,7 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-search.js
packages/app-cli/app/command-server.js
packages/app-cli/app/command-set.test.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
Expand Down Expand Up @@ -1869,6 +1872,7 @@ packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.test.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
Expand Down
5 changes: 4 additions & 1 deletion packages/app-cli/app/base-command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { _ } from '@joplin/lib/locale';
import { reg } from '@joplin/lib/registry.js';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stdout can be called with formatted strings or arbitrary values
type StdoutFn = (text: any)=> void;
Expand All @@ -18,8 +20,9 @@ export default class BaseCommand {
throw new Error('Usage not defined');
}

public encryptionCheck(item: { encryption_applied?: number } | null) {
public encryptionCheck(item: { encryption_applied?: number; is_locked?: number } | null) {
if (item && item.encryption_applied) throw new Error(_('Cannot change encrypted item'));
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot change a locked note'));
}

public description(): string {
Expand Down
26 changes: 26 additions & 0 deletions packages/app-cli/app/command-cat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-cat');

describe('command-cat', () => {

beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});

it('should refuse to display a locked note', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });

let output = '';
const command = setupCommandForTesting(Command, (text: string) => { output += text; });

await expect(command.action({ note: note.id, options: {} })).rejects.toThrow('locked note');
expect(output).toBe('');
});

});
3 changes: 3 additions & 0 deletions packages/app-cli/app/command-cat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { _ } from '@joplin/lib/locale';
import { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';

class Command extends BaseCommand {
public override usage() {
Expand All @@ -23,6 +25,7 @@ class Command extends BaseCommand {

const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
if (!item) throw new Error(_('Cannot find "%s".', title));
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot display a locked note'));

let content = '';

Expand Down
29 changes: 29 additions & 0 deletions packages/app-cli/app/command-edit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as fs from 'fs-extra';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-edit');

describe('command-edit', () => {

beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});

it('should refuse to edit a locked note without writing a temp file', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
const tempDir = Setting.value('tempDir');
await fs.ensureDir(tempDir);
const filesBefore = await fs.readdir(tempDir);

const command = setupCommandForTesting(Command);

await expect(command.action({ note: note.id })).rejects.toThrow('locked note');
expect(await fs.readdir(tempDir)).toEqual(filesBefore);
});

});
44 changes: 44 additions & 0 deletions packages/app-cli/app/command-set.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-set');

describe('command-set', () => {

beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});

// This guard lives in BaseCommand.encryptionCheck, so it also covers attach, done, edit, ren and todo.
it.each([
{ label: 'refuse to change a locked note', flagEnabled: true, expectedError: 'Cannot change a locked note', expectedTitle: 'hello' },
{ label: 'change a locked note when note lock is disabled', flagEnabled: false, expectedError: null, expectedTitle: 'renamed' },
])('should $label', async ({ flagEnabled, expectedError, expectedTitle }) => {
Setting.setValue('featureFlag.noteLock', flagEnabled);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });

const command = setupCommandForTesting(Command);
let error: string|null = null;
try {
await command.action({ note: note.id, name: 'title', value: 'renamed' });
} catch (e) {
error = (e as Error).message;
}

expect(error).toBe(expectedError);
expect((await Note.load(note.id)).title).toBe(expectedTitle);
});

it('should block changing the lock state of a plain note', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'plain', parent_id: '' });

const command = setupCommandForTesting(Command);
await expect(command.action({ note: note.id, name: 'is_locked', value: '1' })).rejects.toThrow('The note lock state cannot be changed from the command line');
expect((await Note.load(note.id)).is_locked).toBe(0);
});

});
6 changes: 6 additions & 0 deletions packages/app-cli/app/command-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { _ } from '@joplin/lib/locale';
import { ModelType } from '@joplin/lib/BaseModel';
import Database from '@joplin/lib/database';
import Note from '@joplin/lib/models/Note';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';

class Command extends BaseCommand {
public override usage() {
Expand Down Expand Up @@ -34,6 +35,11 @@ class Command extends BaseCommand {
for (let i = 0; i < notes.length; i++) {
this.encryptionCheck(notes[i]);

// Enabling the lock here would only fail deep inside the gated save, so refuse it outright.
if (isNoteLockEnabled() && propName === 'is_locked') {
throw new Error(_('The note lock state cannot be changed from the command line'));
}

const timestamp = Date.now();

const newNote: Record<string, unknown> = {
Expand Down
97 changes: 96 additions & 1 deletion packages/lib/commands/convertNoteToMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
import { defaultState, State } from '../reducer';
import Note from '../models/Note';
import { MarkupLanguage } from '@joplin/renderer';
import { setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import { encryptionService, setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import Folder from '../models/Folder';
import { NoteEntity } from '../services/database/types';
import shim from '../shim';
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;
Expand Down Expand Up @@ -106,4 +111,94 @@ describe('convertNoteToMarkdown', () => {
expect(shim.showToast).toHaveBeenCalled();
});

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: 1 });
state.selectedNoteIds = [htmlNote.id];

await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });

expect(shim.showErrorDialog).toHaveBeenCalledTimes(blocked ? 1 : 0);
// The original note is only moved to the trash once it has been converted.
expect((await Note.load(htmlNote.id)).deleted_time === 0).toBe(blocked);
});

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((await Note.load(htmlNote.id)).deleted_time).not.toBe(0);
});

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);
}
});

});
24 changes: 21 additions & 3 deletions packages/lib/commands/convertNoteToMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { itemIsReadOnly } from '../models/utils/readOnly';
import { ModelType } from '../BaseModel';
import ItemChange from '../models/ItemChange';
import Setting from '../models/Setting';
import isNoteLockEnabled from '../services/noteLock/isNoteLockEnabled';
import NoteLockNote from '../services/noteLock/NoteLockNote';
import NoteLockSession from '../services/noteLock/NoteLockSession';
import type { DecryptedNoteLockKey } from '../services/noteLock/NoteLockKey';
import Logger from '@joplin/utils/Logger';

const logger = Logger.create('convertNoteToMarkdown');
Expand All @@ -33,6 +37,7 @@ export const runtime = (): CommandRuntime => {
try {
let isFirst = true;
let processedCount = 0;
let noteLockKey: DecryptedNoteLockKey = null;
for (const note of notes) {
if (note.markup_language === MarkupLanguage.Markdown) {
logger.warn('Skipping item: Already Markdown.');
Expand All @@ -41,8 +46,20 @@ export const runtime = (): CommandRuntime => {
if (await itemIsReadOnly(Note, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note.id, Setting.value('sync.userId'), context.state.shareService)) {
throw new Error(_('Cannot convert read-only item: "%s"', note.title));
}
const noteIsLocked = isNoteLockEnabled() && NoteLockNote.isLocked(note);
if (noteIsLocked && !noteLockKey) {
if (!NoteLockSession.instance().isUnlocked()) {
throw new Error(_('Cannot convert locked note: "%s"', note.title));
}
// Captured once so a session lock mid-run cannot fail the remaining conversions.
noteLockKey = NoteLockSession.instance().decryptedKey();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to first check if any of the notes are locked before the loop, then grab the decrypted key before iterating the loop, so that the key remains available for bulk conversion.

Also, the validation above if the session is locked should apply when the noteLockKey is populated, not just when it is not

}

// A locked note converts through a full gated load and save, so the body is
// decrypted for the conversion and the converted copy is encrypted again.
const sourceNote = noteIsLocked ? await Note.load(note.id, { useNoteLock: true, noteLockKey }) : note;

const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
const markdownBody = await convertHtmlToMarkdown().execute(context, sourceNote.body);
const newNote = await Note.duplicate(note.id);

newNote.body = markdownBody;
Expand All @@ -51,7 +68,8 @@ export const runtime = (): CommandRuntime => {
newNote.user_updated_time = note.user_updated_time;
newNote.updated_time = Date.now();

await Note.save(newNote, { autoTimestamp: false });
const toSave = noteIsLocked ? { ...newNote, isDecrypted: true } : newNote;
await Note.save(toSave, { autoTimestamp: false, useNoteLock: noteIsLocked, noteLockKey });
await Note.delete(note.id, { toTrash: true });
processedCount ++;

Expand All @@ -73,6 +91,6 @@ export const runtime = (): CommandRuntime => {
await shim.showErrorDialog(_('Could not convert notes to Markdown: %s', error.message));
}
},
enabledCondition: 'selectionIncludesHtmlNotes && (multipleNotesSelected || !noteIsReadOnly)',
enabledCondition: 'selectionIncludesHtmlNotes && (multipleNotesSelected || !noteIsReadOnly) && !noteLockContentUnavailable',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the enabledCondition resolves false, does the banner not show (please check both desktop and mobile routes), or does the availability of the banner still need addressing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked both. The unlock and cannot-decrypt panels return before renderConvertHtmlToMarkdown is reached, so the banner can't show when content is unavailable, and mobile doesn't have the convert banner or route at all. One edge, a locked note with unsaved changes keeps the editor mounted when the session locks, so the banner stays and Convert it hits the command's error dialog. Can hide it there too if you want.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes hide it there too please

};
};
Loading
Loading