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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 66 additions & 12 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ use std::path::PathBuf;
use std::sync::Mutex;
use tauri::Manager;

/// Column projection used by every SELECT on the `notes` table.
///
/// Order must match the indices expected by [`note_from_row`].
const NOTE_COLUMNS: &str =
"id, title, content, created_at, updated_at, is_pinned, group_id, is_locked";

/// Application-level wrapper around a mutex-guarded SQLite connection.
///
/// Registered as Tauri managed state via [`init_db`] so that every
Expand Down Expand Up @@ -35,12 +41,14 @@ pub struct Note {
pub is_pinned: bool,
/// The UUID of the group this note belongs to, or `None` for uncategorized.
pub group_id: Option<String>,
/// Whether the note is locked (read-only).
pub is_locked: bool,
}

/// Maps a single result row from the `notes` table to a [`Note`] struct.
///
/// Column order must match the projection used in every SELECT that calls
/// this function: `id, title, content, created_at, updated_at, is_pinned`.
/// this function: `id, title, content, created_at, updated_at, is_pinned, group_id, is_locked`.
pub(crate) fn note_from_row(row: &rusqlite::Row) -> Result<Note, rusqlite::Error> {
Ok(Note {
id: row.get(0)?,
Expand All @@ -50,6 +58,7 @@ pub(crate) fn note_from_row(row: &rusqlite::Row) -> Result<Note, rusqlite::Error
updated_at: row.get(4)?,
is_pinned: row.get(5)?,
group_id: row.get(6)?,
is_locked: row.get(7)?,
})
}

Expand Down Expand Up @@ -123,6 +132,12 @@ pub fn init_db(app: &tauri::AppHandle) -> Result<(), String> {
[],
);

// Migration: add is_locked column if it does not exist.
let _ = conn.execute(
"ALTER TABLE notes ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0",
[],
);

app.manage(DbState(Mutex::new(conn)));
Ok(())
}
Expand All @@ -145,9 +160,7 @@ pub fn init_db(app: &tauri::AppHandle) -> Result<(), String> {
pub fn get_note(state: tauri::State<DbState>, id: String) -> Result<Option<Note>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1",
)
.prepare(&format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"))
.map_err(|e| e.to_string())?;

let note = stmt.query_row([&id], note_from_row).ok();
Expand Down Expand Up @@ -175,9 +188,9 @@ pub fn get_note(state: tauri::State<DbState>, id: String) -> Result<Option<Note>
pub fn list_notes(state: tauri::State<DbState>) -> Result<Vec<Note>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes ORDER BY is_pinned DESC, updated_at DESC",
)
.prepare(&format!(
"SELECT {NOTE_COLUMNS} FROM notes ORDER BY is_pinned DESC, updated_at DESC"
))
.map_err(|e| e.to_string())?;

let notes = stmt
Expand Down Expand Up @@ -216,7 +229,7 @@ pub fn create_note(
let now = chrono::Utc::now().to_rfc3339();

conn.execute(
"INSERT INTO notes (id, title, content, created_at, updated_at, is_pinned, group_id) VALUES (?1, ?2, ?3, ?4, ?5, 0, NULL)",
"INSERT INTO notes (id, title, content, created_at, updated_at, is_pinned, group_id, is_locked) VALUES (?1, ?2, ?3, ?4, ?5, 0, NULL, 0)",
rusqlite::params![id, title, content, now, now],
)
.map_err(|e| e.to_string())?;
Expand All @@ -229,6 +242,7 @@ pub fn create_note(
updated_at: now,
is_pinned: false,
group_id: None,
is_locked: false,
})
}

Expand Down Expand Up @@ -270,7 +284,7 @@ pub fn update_note(

let note = conn
.query_row(
"SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1",
&format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"),
rusqlite::params![id],
note_from_row,
)
Expand Down Expand Up @@ -307,7 +321,7 @@ pub fn toggle_pin(state: tauri::State<DbState>, id: String, pinned: bool) -> Res

let note = conn
.query_row(
"SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1",
&format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"),
rusqlite::params![id],
note_from_row,
)
Expand Down Expand Up @@ -359,7 +373,7 @@ pub fn duplicate_note(state: tauri::State<DbState>, id: String) -> Result<Note,

let source = conn
.query_row(
"SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1",
&format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"),
rusqlite::params![id],
note_from_row,
)
Expand All @@ -374,7 +388,7 @@ pub fn duplicate_note(state: tauri::State<DbState>, id: String) -> Result<Note,
let content = rewrite_first_heading(&source.content, &title);

conn.execute(
"INSERT INTO notes (id, title, content, created_at, updated_at, is_pinned, group_id) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)",
"INSERT INTO notes (id, title, content, created_at, updated_at, is_pinned, group_id, is_locked) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, 0)",
rusqlite::params![new_id, title, content, now, now, source.group_id],
)
.map_err(|e| e.to_string())?;
Expand All @@ -387,9 +401,49 @@ pub fn duplicate_note(state: tauri::State<DbState>, id: String) -> Result<Note,
updated_at: now,
is_pinned: false,
group_id: source.group_id,
is_locked: false,
})
}

/// Toggles the locked (read-only) state of a note.
///
/// When a note is locked, the frontend disables editing and suppresses auto-save.
///
/// # Arguments
///
/// * `state` - Managed database state injected by Tauri.
/// * `id` - The UUID of the note to lock or unlock.
/// * `locked` - `true` to lock, `false` to unlock.
///
/// # Returns
///
/// The updated [`Note`] as it exists in the database after the write.
///
/// # Errors
///
/// Returns a `String` if the database lock is poisoned, the UPDATE fails,
/// or the note is not found after the update.
#[tauri::command]
pub fn toggle_lock(state: tauri::State<DbState>, id: String, locked: bool) -> Result<Note, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;

conn.execute(
"UPDATE notes SET is_locked = ?1, updated_at = datetime('now') WHERE id = ?2",
rusqlite::params![locked, id],
)
.map_err(|e| e.to_string())?;

let note = conn
.query_row(
&format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"),
rusqlite::params![id],
note_from_row,
)
.map_err(|e| e.to_string())?;

Ok(note)
}

/// Rewrites the text of the first heading block in a BlockNote content JSON.
///
/// BlockNote content is a JSON array of block objects. Each block may have a
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub fn run() {
db::delete_note,
db::duplicate_note,
db::toggle_pin,
db::toggle_lock,
groups::list_groups,
groups::create_group,
groups::rename_group,
Expand Down
39 changes: 38 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
getNote,
listNotes,
readTextFile,
toggleLockNote,
togglePinNote,
useCommandPaletteScroll,
useCursorAutoHideEffect,
Expand Down Expand Up @@ -78,6 +79,7 @@ function AppContent() {
const [sidebarOpen, setSidebarOpen] = useState(configDefaults.sidebarOpen)
const [refreshKey, setRefreshKey] = useState(0)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [isNoteLocked, setIsNoteLocked] = useState(false)
const editorRef = useRef<EditorHandle>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { isHidden: isHeaderHidden } = useScrollDirection(scrollContainerRef, {
Expand Down Expand Up @@ -230,6 +232,7 @@ function AppContent() {
saveCursorPosition(selectedNoteId)
}
setSelectedNoteId(id)
setIsNoteLocked(false)
persistLastNoteId(id)
},
[selectedNoteId, saveScrollPosition, saveCursorPosition, persistLastNoteId]
Expand Down Expand Up @@ -271,6 +274,15 @@ function AppContent() {
setRefreshKey((v) => v + 1)
}, [])

/**
* Callback invoked when the lock state of the loaded note is determined.
*
* @param locked - `true` if the note is locked, `false` otherwise.
*/
const handleLockStateChange = useCallback((locked: boolean) => {
setIsNoteLocked(locked)
}, [])

/**
* Creates a new note with default content and selects it.
*
Expand Down Expand Up @@ -332,6 +344,28 @@ function AppContent() {
[]
)

/**
* Toggles the locked state of the given note.
*
* @param noteId - The ID of the note whose lock state should be toggled.
* @param locked - The new locked state to apply.
* @throws Shows an error toast if the toggle operation fails.
*/
const handleToggleLock = useCallback(
async (noteId: string, locked: boolean) => {
try {
await toggleLockNote(noteId, locked)
if (selectedNoteId === noteId) {
setIsNoteLocked(locked)
}
setRefreshKey((v) => v + 1)
} catch {
toast.error('Failed to toggle lock')
}
},
[selectedNoteId]
)

/**
* Duplicates the specified note and selects the newly created copy.
*
Expand Down Expand Up @@ -429,6 +463,7 @@ function AppContent() {
onNewNote={handleNewNote}
onDeleteNote={handleDeleteNote}
onTogglePin={handleTogglePin}
onToggleLock={handleToggleLock}
onDuplicateNote={handleDuplicateNote}
onExportNote={handleExportNote}
onImportNote={handleImportNote}
Expand All @@ -453,16 +488,18 @@ function AppContent() {
className="custom-scrollbar flex-1 overflow-y-auto overscroll-none"
>
<div className="pointer-events-none sticky top-5 z-10 flex justify-end pr-7">
<SaveStatusIndicator status={saveStatus} />
<SaveStatusIndicator status={saveStatus} locked={isNoteLocked} />
</div>
<Editor
ref={editorRef}
key={selectedNoteId ?? 'new'}
noteId={selectedNoteId}
locked={isNoteLocked}
onNoteSaved={handleNoteSaved}
onStatusChange={setSaveStatus}
onContentLoaded={handleContentLoaded}
onSuggestionMenuOpen={scrollCursorToTop}
onLockStateChange={handleLockStateChange}
/>
<div className="pointer-events-none sticky bottom-5 z-10 flex justify-end pr-7">
<ScrollToTopButton
Expand Down
15 changes: 15 additions & 0 deletions src/features/editor/api/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface Note {
updatedAt: string
isPinned: boolean
groupId: string | null
isLocked: boolean
}

/**
Expand Down Expand Up @@ -89,6 +90,20 @@ export async function togglePinNote(
return invoke<Note>('toggle_pin', { id, pinned })
}

/**
* Toggles the locked (read-only) state of a note.
*
* @param id - The UUID of the note to lock or unlock.
* @param locked - `true` to lock, `false` to unlock.
* @returns The updated note as it exists in the database after the write.
*/
export async function toggleLockNote(
id: string,
locked: boolean
): Promise<Note> {
return invoke<Note>('toggle_lock', { id, locked })
}

/**
* Duplicates an existing note.
*
Expand Down
1 change: 1 addition & 0 deletions src/features/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
getNote,
listNotes,
readTextFile,
toggleLockNote,
togglePinNote,
updateNote,
writeTextFile,
Expand Down
51 changes: 51 additions & 0 deletions src/features/editor/lib/readOnlyGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { createExtension } from '@blocknote/core'
import { Plugin, PluginKey } from 'prosemirror-state'

/**
* Plugin key used to identify the read-only guard plugin.
*/
const PLUGIN_KEY = new PluginKey('readOnlyGuard')

/**
* Module-level flag that controls whether the editor should block all
* editing transactions. Set by the React component via {@link setReadOnly}.
*
* Using a module-level variable (instead of React state) allows the
* ProseMirror plugin's `filterTransaction` to read it synchronously
* without requiring the plugin to be recreated on every state change.
*/
let readOnly = false

/**
* Updates the read-only flag. Called from the Editor React component
* whenever the `locked` prop changes.
*/
export function setReadOnly(value: boolean): void {
readOnly = value
}

/**
* BlockNote extension that blocks all editing transactions when the
* read-only flag is `true`.
*
* Transactions with `addToHistory === false` meta are allowed through so
* that programmatic content loads (e.g. `replaceBlocks` during note
* switching) still work. Selection-only transactions (cursor movement)
* are also allowed so the user can position the cursor for copying.
*/
export const readOnlyGuardExtension = createExtension({
key: 'readOnlyGuard',
prosemirrorPlugins: [
new Plugin({
key: PLUGIN_KEY,
filterTransaction: (tr) => {
if (!readOnly) return true
// Allow programmatic content loads that suppress history.
if (tr.getMeta('addToHistory') === false) return true
// Allow selection-only transactions (cursor movement, text selection).
if (tr.docChanged) return false
return true
},
}),
],
})
Loading
Loading