diff --git a/package-lock.json b/package-lock.json index ab82119..0308b11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/main.ts b/src/main.ts index 0ab3848..394f754 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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), }); } diff --git a/src/models/types.ts b/src/models/types.ts index ff5cd88..0c512e8 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -1,5 +1,6 @@ export interface NoteLockerSettings { lockedNotes: Set; + lockedFolders: Set; 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, diff --git a/src/settings.ts b/src/settings.ts index d9f20df..8391842 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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' : ''}.` }); } } diff --git a/src/ui/fileExplorer.ts b/src/ui/fileExplorer.ts index c471a4c..86ac808 100644 --- a/src/ui/fileExplorer.ts +++ b/src/ui/fileExplorer.ts @@ -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 {