Initial implementation of folder lock feature

* There are several other improvements

Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
This commit is contained in:
Felvesthe 2025-11-23 00:06:01 +01:00
parent 4b27d16d25
commit 98875376b9
5 changed files with 226 additions and 85 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "note-locker",
"version": "1.0.0",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "note-locker",
"version": "1.0.0",
"version": "1.2.1",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",

View file

@ -71,7 +71,7 @@ export default class NoteLockerPlugin extends Plugin {
);
this.registerEvent(
this.app.workspace.on("editor-menu",(menu, _, view) =>
this.app.workspace.on("editor-menu", (menu, _, view) =>
view.file && this.addLockMenuItem(menu, view.file.path)
)
);
@ -85,12 +85,25 @@ export default class NoteLockerPlugin extends Plugin {
this.registerEvent(
this.app.vault.on("rename", async (file, oldPath) => {
let settingsChanged = false;
// Handle locked notes
if (this.settings.lockedNotes.delete(oldPath)) {
if (!this.settings.lockedNotes.has(file.path)) {
this.settings.lockedNotes.add(file.path);
} else {
new Notice(`⚠️ Lock skipped: "${file.name}" was already locked`);
settingsChanged = true;
}
}
// Handle locked folders
if (this.settings.lockedFolders.delete(oldPath)) {
if (!this.settings.lockedFolders.has(file.path)) {
this.settings.lockedFolders.add(file.path);
settingsChanged = true;
}
}
if (settingsChanged) {
await this.saveSettings();
this.fileExplorerUI.updateFileExplorerIcons();
}
@ -117,17 +130,69 @@ export default class NoteLockerPlugin extends Plugin {
.forEach((leaf) => this.updateLeafMode(leaf));
}
private addLockMenuItem(menu: Menu, filePath: string) {
const isNote = filePath.endsWith('.md');
if (!isNote) return;
private addLockMenuItem(menu: Menu, path: string) {
if (this.isParentFolderLocked(path)) {
return;
}
const isLocked = this.settings.lockedNotes.has(filePath);
menu.addItem((item) =>
item
.setTitle(isLocked ? "Unlock" : "Lock")
.setIcon(isLocked ? "unlock" : "lock")
.onClick(() => this.toggleNoteLock(filePath))
);
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile && file.extension === 'md') {
const isLocked = this.settings.lockedNotes.has(path);
menu.addItem((item) =>
item
.setTitle(isLocked ? "Unlock" : "Lock")
.setIcon(isLocked ? "unlock" : "lock")
.onClick(() => this.toggleNoteLock(path))
);
} else if (file && !(file instanceof TFile)) {
const isLocked = this.settings.lockedFolders.has(path);
menu.addItem((item) =>
item
.setTitle(isLocked ? "Unlock all notes in folder" : "Lock all notes in folder")
.setIcon(isLocked ? "unlock" : "lock")
.onClick(() => this.toggleFolderLock(path))
);
}
}
private isParentFolderLocked(path: string): boolean {
let currentPath = path;
while (currentPath.includes("/")) {
currentPath = currentPath.substring(0, currentPath.lastIndexOf("/"));
if (this.settings.lockedFolders.has(currentPath)) {
return true;
}
}
return false;
}
async toggleFolderLock(folderPath: string) {
const isLocked = this.settings.lockedFolders.has(folderPath);
isLocked
? this.settings.lockedFolders.delete(folderPath)
: this.settings.lockedFolders.add(folderPath);
await this.saveSettings();
new Notice(`${isLocked ? '🔓 Unlocked' : '🔒 Locked'} folder: ${folderPath}`);
this.updateAllNotesInFolder(folderPath);
this.statusBarUI.updateStatusBarButton();
this.fileExplorerUI.updateFileExplorerIcons();
}
private updateAllNotesInFolder(folderPath: string) {
this.app.workspace
.getLeavesOfType("markdown")
.forEach((leaf) => {
if (leaf.view instanceof MarkdownView && leaf.view.file) {
if (leaf.view.file.path.startsWith(folderPath + "/")) {
this.updateLeafMode(leaf);
}
}
});
}
async toggleNoteLock(notePath: string) {
@ -180,12 +245,25 @@ export default class NoteLockerPlugin extends Plugin {
return view instanceof MarkdownView && view.file?.path === notePath;
}
public isPathLocked(path: string): boolean {
if (this.settings.lockedNotes.has(path)) return true;
let currentPath = path;
while (currentPath.includes("/")) {
currentPath = currentPath.substring(0, currentPath.lastIndexOf("/"));
if (this.settings.lockedFolders.has(currentPath)) {
return true;
}
}
return false;
}
private updateLeafMode(leaf: WorkspaceLeaf | null) {
if (!leaf || !(leaf.view instanceof MarkdownView)) return;
const { view } = leaf;
const targetMode =
view.file && this.settings.lockedNotes.has(view.file.path)
view.file && this.isPathLocked(view.file.path)
? "preview"
: "source";
@ -203,7 +281,8 @@ export default class NoteLockerPlugin extends Plugin {
this.settings = {
...DEFAULT_SETTINGS,
...loaded,
lockedNotes: new Set(loaded.lockedNotes || [])
lockedNotes: new Set(loaded.lockedNotes || []),
lockedFolders: new Set(loaded.lockedFolders || [])
};
}
}
@ -212,6 +291,7 @@ export default class NoteLockerPlugin extends Plugin {
await this.saveData({
...this.settings,
lockedNotes: Array.from(this.settings.lockedNotes),
lockedFolders: Array.from(this.settings.lockedFolders),
});
}

View file

@ -1,5 +1,6 @@
export interface NoteLockerSettings {
lockedNotes: Set<string>;
lockedFolders: Set<string>;
mobileNotificationMaxLength: number;
desktopNotificationMaxLength: number;
showFileExplorerIcons: boolean;
@ -9,6 +10,7 @@ export interface NoteLockerSettings {
export const DEFAULT_SETTINGS: NoteLockerSettings = {
lockedNotes: new Set(),
lockedFolders: new Set(),
mobileNotificationMaxLength: 18,
desktopNotificationMaxLength: 22,
showFileExplorerIcons: true,

View file

@ -1,4 +1,4 @@
import {App, Platform, PluginSettingTab, Setting} from "obsidian";
import { App, Platform, PluginSettingTab, Setting } from "obsidian";
import type NoteLockerPlugin from "./main";
export class NoteLockerSettingTab extends PluginSettingTab {
@ -80,10 +80,10 @@ export class NoteLockerSettingTab extends PluginSettingTab {
});
hotkeyInfo.style.fontStyle = 'italic';
containerEl.createEl('h3', { text: 'Locked Notes' });
containerEl.createEl('h3', { text: 'Locked Notes & Folders' });
const lockedNotesCount = this.plugin.settings.lockedNotes.size;
containerEl.createEl('p', {
text: `You currently have ${lockedNotesCount} locked note${lockedNotesCount !== 1 ? 's' : ''}.`
text: `You currently have ${lockedNotesCount} locked note${lockedNotesCount !== 1 ? 's' : ''} and ${this.plugin.settings.lockedFolders.size} locked folder${this.plugin.settings.lockedFolders.size !== 1 ? 's' : ''}.`
});
}
}

View file

@ -15,21 +15,20 @@ export class FileExplorerUI {
styleEl.id = 'note-locker-styles';
styleEl.textContent = `
.note-locker-icon {
display: inline-flex;
display: flex;
justify-content: center;
align-items: center;
margin-left: 4px;
width: 12px;
height: 12px;
font-size: 0.85em;
margin-left: 6px;
width: 14px;
height: 14px;
color: var(--text-accent);
vertical-align: middle;
opacity: 0.8;
}
.nav-file-title {
.nav-file-title, .nav-folder-title {
display: flex;
align-items: center;
}
.nav-file-title-content {
.nav-file-title-content, .nav-folder-title-content {
flex: 1;
min-width: 0;
overflow: hidden;
@ -38,43 +37,12 @@ export class FileExplorerUI {
`;
document.head.appendChild(styleEl);
// Initial update
this.plugin.app.workspace.onLayoutReady(() => {
this.updateFileExplorerIcons();
this.setupFolderObserver();
// Register handlers for folder expansion/collapse
this.registerFolderClickHandlers();
});
}
private registerFolderClickHandlers(): void {
const fileExplorer = document.querySelector('.workspace-split.mod-left-split .nav-files-container');
if (!fileExplorer) return;
fileExplorer.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const folderCollapseIndicator = target.closest('.nav-folder-collapse-indicator') ||
target.closest('.nav-folder-title');
if (folderCollapseIndicator) {
// A folder was clicked, so schedule an update after DOM changes
this.debouncedUpdateIcons(250);
}
});
}
private debouncedUpdateIcons(delay: number = 100): void {
if (this.updateDebounceTimeout !== null) {
window.clearTimeout(this.updateDebounceTimeout);
}
this.updateDebounceTimeout = window.setTimeout(() => {
this.updateFileExplorerIcons();
this.updateDebounceTimeout = null;
}, delay);
}
private setupFolderObserver(): void {
if (this.folderObserver) {
this.folderObserver.disconnect();
@ -88,59 +56,150 @@ export class FileExplorerUI {
let shouldUpdate = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0) {
shouldUpdate = true;
break;
if (mutation.target instanceof HTMLElement &&
(mutation.target.classList.contains('note-locker-icon') ||
mutation.target.closest('.note-locker-icon'))) {
continue;
}
if (mutation.type === 'attributes' &&
mutation.target instanceof HTMLElement &&
(mutation.target.classList.contains('nav-folder') ||
mutation.target.classList.contains('is-collapsed'))) {
shouldUpdate = true;
break;
}
}
if (mutation.type === 'childList') {
const target = mutation.target as HTMLElement;
const isTitle = target.classList.contains('nav-file-title') || target.classList.contains('nav-folder-title');
if (shouldUpdate) {
this.debouncedUpdateIcons(150);
let shouldProcess = false;
for (let i = 0; i < mutation.addedNodes.length; i++) {
const node = mutation.addedNodes[i];
if (!(node instanceof HTMLElement && node.classList.contains('note-locker-icon'))) {
shouldProcess = true;
break;
}
}
if (!shouldProcess) {
for (let i = 0; i < mutation.removedNodes.length; i++) {
shouldProcess = true;
break;
}
}
if (shouldProcess) {
if (isTitle) {
const container = target.parentElement;
if (container) this.processItem(container);
} else {
this.handleAddedNodes(mutation.addedNodes);
}
}
} else if (mutation.type === 'attributes') {
if (mutation.target instanceof HTMLElement) {
if (mutation.target.classList.contains('nav-file') || mutation.target.classList.contains('nav-folder')) {
this.processItem(mutation.target);
}
}
}
}
});
this.folderObserver.observe(fileExplorer, {
childList: true,
attributes: true,
attributeFilter: ['class'],
attributeFilter: ['class', 'data-path', 'is-collapsed'],
subtree: true
});
}
private handleAddedNodes(nodeList: NodeList): void {
nodeList.forEach((node) => {
if (node instanceof HTMLElement) {
if (node.classList.contains('nav-file') || node.classList.contains('nav-folder')) {
this.processItem(node);
}
if (node.classList.contains('nav-file-title') || node.classList.contains('nav-folder-title')) {
const container = node.parentElement;
if (container) this.processItem(container);
}
const items = node.querySelectorAll('.nav-file, .nav-folder');
items.forEach(item => this.processItem(item as HTMLElement));
const titles = node.querySelectorAll('.nav-file-title, .nav-folder-title');
titles.forEach(title => {
const container = title.parentElement;
if (container) this.processItem(container);
});
}
});
}
private processItem(item: HTMLElement): void {
const titleEl = item.querySelector('.nav-file-title, .nav-folder-title');
if (!titleEl) return;
const path = titleEl.getAttribute('data-path');
if (!path) return;
let shouldHaveIcon = false;
if (item.classList.contains('nav-file')) {
shouldHaveIcon = this.plugin.settings.lockedNotes.has(path);
} else if (item.classList.contains('nav-folder')) {
shouldHaveIcon = this.plugin.settings.lockedFolders.has(path);
}
this.updateIconState(titleEl, shouldHaveIcon);
}
public updateFileExplorerIcons(): void {
if (!this.plugin.settings.showFileExplorerIcons) {
this.removeFileExplorerIcons();
return;
}
// First, remove existing icons to avoid duplicates
this.removeFileExplorerIcons();
// Get all file elements in the file explorer
// Handle files
const fileItems = document.querySelectorAll('.nav-file');
fileItems.forEach((fileItem) => {
const titleEl = fileItem.querySelector('.nav-file-title');
if (!titleEl) return;
const filePath = titleEl.getAttribute('data-path');
if (!filePath || !this.plugin.settings.lockedNotes.has(filePath)) return;
if (!filePath) return;
// Create lock icon element
const iconEl = document.createElement('div');
iconEl.addClass('note-locker-icon');
setIcon(iconEl, 'lock');
titleEl.appendChild(iconEl);
// Only show icon if file is explicitly locked
const shouldHaveIcon = this.plugin.settings.lockedNotes.has(filePath);
this.updateIconState(titleEl, shouldHaveIcon);
});
// Handle folders
const folderItems = document.querySelectorAll('.nav-folder');
folderItems.forEach((folderItem) => {
const titleEl = folderItem.querySelector('.nav-folder-title');
if (!titleEl) return;
const folderPath = titleEl.getAttribute('data-path');
if (!folderPath) return;
const shouldHaveIcon = this.plugin.settings.lockedFolders.has(folderPath);
this.updateIconState(titleEl, shouldHaveIcon);
});
}
private updateIconState(targetEl: Element, shouldHaveIcon: boolean): void {
const existingIcon = targetEl.querySelector('.note-locker-icon');
if (shouldHaveIcon) {
if (!existingIcon) {
const iconEl = document.createElement('div');
iconEl.addClass('note-locker-icon');
setIcon(iconEl, 'lock');
targetEl.appendChild(iconEl);
}
} else {
if (existingIcon) {
existingIcon.remove();
}
}
}
public removeFileExplorerIcons(): void {