Compare commits

...

33 commits

Author SHA1 Message Date
Felvesthe
4a2fbbbb9d fix: Reset edit mode when navigating to another locked note
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-12-05 13:10:01 +01:00
Felvesthe
1bbbb63f90 Use different icon for strictly locked notes
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-12-05 12:40:04 +01:00
Felvesthe
7d377dc99d Add file existence check & fix an issue that allowed notifications to show even if disabled for strict lock
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-12-02 00:12:34 +01:00
Felvesthe
cd4ecd7821 Change Title Case to Sentence case to match Obsidian's plugin guidelines
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-12-02 00:02:48 +01:00
Felvesthe
cf02579f86 Bump version to 1.3.1
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-11-30 18:31:11 +01:00
Felvesthe
f990abb7ef Add option to prevent editing in normally locked notes
* Add "Prevent editing in locked notes" setting (default: off)
* Hide edit buttons when prevention is enabled
* Force preview mode when prevention is enabled for locked notes
* Normally locked notes are editable when prevention is disabled

Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-11-30 18:30:37 +01:00
Felvesthe
07efeb3553 Bump version to 1.3.0
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-11-23 13:47:18 +01:00
Felvesthe
2c961c9888 settings: Change statistics view
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-11-23 13:45:42 +01:00
Felvesthe
3f1653f967 Initial implementation of strict lock feature
* https://github.com/Felvesthe/Note-Locker/issues/6

Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-11-23 13:42:13 +01:00
Felvesthe
eec6cb8de2 Implement possibility to lock/unlock multiple notes at once
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-11-23 00:40:10 +01:00
Felvesthe
98875376b9 Initial implementation of folder lock feature
* There are several other improvements

Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-11-23 00:08:25 +01:00
Felvesthe
4b27d16d25 Merge remote-tracking branch 'obsidian/master'
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-11-22 23:23:26 +01:00
Johannes Theiner
e2a64e0534
Merge pull request #151 from adamu/main
Prefix unused variables with _
2025-09-17 18:12:44 +02:00
Johannes Theiner
8933f6ce64
Merge pull request #142 from johannrichard/declutter-version-bump
build: only write new minAppVersion requirements to `versions.json`
2025-09-17 17:54:33 +02:00
Steph Ango
db97f5f629
Merge pull request #155 from obsidianmd/agents-md
AGENTS.md
2025-09-05 09:12:10 -07:00
Steph Ango
9673533aa9 language 2025-09-05 09:10:47 -07:00
Steph Ango
33075ecd13 use forward slashes for cross OS compatibility 2025-09-05 09:04:04 -07:00
Steph Ango
188bb6120f small copy tweaks 2025-09-05 09:01:58 -07:00
Steph Ango
a4398b8ecc Corrections based on feedback 2025-09-05 08:58:49 -07:00
Steph Ango
ce4fc8c209 First pass 2025-09-04 15:28:25 -07:00
Adam Millerchip
f16c1401b3 Prefix unused variables with _ 2025-08-28 14:38:40 +09:00
Felvesthe
ac1979f0f5 Bump version to 1.2.1
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-16 22:00:07 +02:00
Felvesthe
0a2336814a Fix lock icon visibility after folder expansion
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-16 21:57:28 +02:00
Felvesthe
2dd1faa084 Bump version to 1.2.0
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-11 01:39:28 +02:00
Felvesthe
688142f84e Add setting for note lock/unlock notifications
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-11 01:38:32 +02:00
Felvesthe
09a6673606 Observe entire file explorer to properly display lock icons
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-11 01:33:35 +02:00
Felvesthe
7dba2bdf39 Bring back separate notification max length settings for mobile and desktop
* Unified setting causes issues with cross-device config sync
* Hide desktop setting on mobile, and mobile setting on desktop

Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-11 01:04:15 +02:00
Felvesthe
b6822b20c9 Bump version to 1.1.0
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-09 17:49:26 +02:00
Felvesthe
90775b8b9b Add hotkey option
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-09 17:45:57 +02:00
Felvesthe
fe4652abc1 Exclude "Lock" option in context menu for folders
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-09 15:27:55 +02:00
Felvesthe
38c5a0e500 Code refactor and new features
* Added settings panel
* Unified mobile / desktop notification max length setting (configurable in settings)
* New option: Lock / Unlock icon in status bar
* New option: Show lock icon next to locked note name

Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-05-09 15:23:00 +02:00
Johann Richard
3fe07677b5
build: only write new minAppVersion requirements to versions.json
Only add a new version requirements if `minAppVersion` is not already in `versions.json`. Should declutter `versions.json`.
2025-04-28 08:38:38 +02:00
Felvesthe
1e758a610c Update plugin description in manifest
Signed-off-by: Felvesthe <23108389+Felvesthe@users.noreply.github.com>
2025-04-15 22:29:21 +02:00
15 changed files with 3801 additions and 161 deletions

251
AGENTS.md Normal file
View file

@ -0,0 +1,251 @@
# Obsidian community plugin
## Project overview
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
## Environment & tooling
- Node.js: use current LTS (Node 18+ recommended).
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
- Types: `obsidian` type definitions.
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
### Install
```bash
npm install
```
### Dev (watch)
```bash
npm run dev
```
### Production build
```bash
npm run build
```
## Linting
- To use eslint install eslint from terminal: `npm install -g eslint`
- To use eslint to analyze this project use this command: `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
## File & folder conventions
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
- **Example file structure**:
```
src/
main.ts # Plugin entry point, lifecycle management
settings.ts # Settings interface and defaults
commands/ # Command implementations
command1.ts
command2.ts
ui/ # UI components, modals, views
modal.ts
view.ts
utils/ # Utility functions, helpers
helpers.ts
constants.ts
types.ts # TypeScript interfaces and types
```
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
## Manifest rules (`manifest.json`)
- Must include (non-exhaustive):
- `id` (plugin ID; for local dev it should match the folder name)
- `name`
- `version` (Semantic Versioning `x.y.z`)
- `minAppVersion`
- `description`
- `isDesktopOnly` (boolean)
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
- Never change `id` after release. Treat it as stable API.
- Keep `minAppVersion` accurate when using newer APIs.
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
## Testing
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
```
<Vault>/.obsidian/plugins/<plugin-id>/
```
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
## Commands & settings
- Any user-facing commands should be added via `this.addCommand(...)`.
- If the plugin has configuration, provide a settings tab and sensible defaults.
- Persist settings using `this.loadData()` / `this.saveData()`.
- Use stable command IDs; avoid renaming once released.
## Versioning & releases
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
## Security, privacy, and compliance
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
- Default to local/offline operation. Only make network requests when essential to the feature.
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
- Clearly disclose any external services used, data sent, and risks.
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
- Avoid deceptive patterns, ads, or spammy notifications.
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
## UX & copy guidelines (for UI text, commands, settings)
- Prefer sentence case for headings, buttons, and titles.
- Use clear, action-oriented imperatives in step-by-step copy.
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
- Use arrow notation for navigation: **Settings → Community plugins**.
- Keep in-app strings short, consistent, and free of jargon.
## Performance
- Keep startup light. Defer heavy work until needed.
- Avoid long-running tasks during `onload`; use lazy initialization.
- Batch disk access and avoid excessive vault scans.
- Debounce/throttle expensive operations in response to file system events.
## Coding conventions
- TypeScript with `"strict": true` preferred.
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
- Bundle everything into `main.js` (no unbundled runtime deps).
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
- Prefer `async/await` over promise chains; handle errors gracefully.
## Mobile
- Where feasible, test on iOS and Android.
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
- Avoid large in-memory structures; be mindful of memory and storage constraints.
## Agent do/don't
**Do**
- Add commands with stable IDs (don't rename once released).
- Provide defaults and validation in settings.
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
- Use `this.register*` helpers for everything that needs cleanup.
**Don't**
- Introduce network calls without an obvious user-facing reason and documentation.
- Ship features that require cloud services without clear disclosure and explicit opt-in.
- Store or transmit vault contents unless essential and consented.
## Common tasks
### Organize code across multiple files
**main.ts** (minimal, lifecycle only):
```ts
import { Plugin } from "obsidian";
import { MySettings, DEFAULT_SETTINGS } from "./settings";
import { registerCommands } from "./commands";
export default class MyPlugin extends Plugin {
settings: MySettings;
async onload() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
registerCommands(this);
}
}
```
**settings.ts**:
```ts
export interface MySettings {
enabled: boolean;
apiKey: string;
}
export const DEFAULT_SETTINGS: MySettings = {
enabled: true,
apiKey: "",
};
```
**commands/index.ts**:
```ts
import { Plugin } from "obsidian";
import { doSomething } from "./my-command";
export function registerCommands(plugin: Plugin) {
plugin.addCommand({
id: "do-something",
name: "Do something",
callback: () => doSomething(plugin),
});
}
```
### Add a command
```ts
this.addCommand({
id: "your-command-id",
name: "Do the thing",
callback: () => this.doTheThing(),
});
```
### Persist settings
```ts
interface MySettings { enabled: boolean }
const DEFAULT_SETTINGS: MySettings = { enabled: true };
async onload() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
await this.saveData(this.settings);
}
```
### Register listeners safely
```ts
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
this.registerDomEvent(window, "resize", () => { /* ... */ });
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
```
## Troubleshooting
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
## References
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
- API documentation: https://docs.obsidian.md
- Developer policies: https://docs.obsidian.md/Developer+policies
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
- Style guide: https://help.obsidian.md/style-guide

View file

@ -50,4 +50,4 @@ The lock will automatically transfer to the new filename.
If locks aren't working properly: If locks aren't working properly:
1. Check for conflicts with other plugins 1. Check for conflicts with other plugins
2. Ensure you're running the latest version of Obsidian 2. Ensure you're running the latest version of Obsidian
3. Try reinstalling the plugin 3. Try reinstalling the plugin

View file

@ -15,7 +15,7 @@ const context = await esbuild.context({
banner: { banner: {
js: banner, js: banner,
}, },
entryPoints: ["main.ts"], entryPoints: ["src/main.ts"],
bundle: true, bundle: true,
external: [ external: [
"obsidian", "obsidian",

148
main.ts
View file

@ -1,148 +0,0 @@
import { MarkdownView, Menu, Notice, Platform, Plugin, TFile, WorkspaceLeaf } from "obsidian";
interface NoteLockerSettings {
lockedNotes: Set<string>;
mobileNotificationMaxLength: number;
desktopNotificationMaxLength: number;
}
const DEFAULT_SETTINGS: NoteLockerSettings = {
lockedNotes: new Set(),
mobileNotificationMaxLength: 18,
desktopNotificationMaxLength: 22
};
export default class NoteLockerPlugin extends Plugin {
settings = DEFAULT_SETTINGS;
async onload() {
await this.loadSettings();
this.registerEventHandlers();
this.initializeExistingLeaves();
}
private registerEventHandlers() {
this.registerEvent(
this.app.workspace.on("file-menu", (menu, file) =>
this.addLockMenuItem(menu, file.path)
)
);
this.registerEvent(
this.app.workspace.on("editor-menu",(menu, _, view) =>
view.file && this.addLockMenuItem(menu, view.file.path)
)
);
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) =>
this.updateLeafMode(leaf)
)
);
this.registerEvent(
this.app.vault.on("rename", async (file, oldPath) => {
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`);
}
await this.saveSettings();
}
})
);
}
private initializeExistingLeaves() {
this.app.workspace
.getLeavesOfType("markdown")
.forEach((leaf) => this.updateLeafMode(leaf));
}
private addLockMenuItem(menu: Menu, filePath: string) {
const isLocked = this.settings.lockedNotes.has(filePath);
menu.addItem((item) =>
item
.setTitle(isLocked ? "Unlock" : "Lock")
.setIcon(isLocked ? "unlock" : "lock")
.onClick(() => this.toggleNoteLock(filePath))
);
}
private async toggleNoteLock(notePath: string) {
const isLocked = this.settings.lockedNotes.has(notePath);
isLocked
? this.settings.lockedNotes.delete(notePath)
: this.settings.lockedNotes.add(notePath);
await this.saveSettings();
const file = this.app.vault.getAbstractFileByPath(notePath);
if (!file) {
new Notice("Error: Note not found");
return;
}
const fileName = file instanceof TFile ? file.basename :
notePath.split('/').pop()?.replace(/\..+$/, '') || notePath;
const displayName = this.truncateFileName(fileName);
new Notice(`${isLocked ? '🔓 Unlocked' : '🔒 Locked'}: ${displayName}`);
this.updateAllNoteInstances(notePath);
}
private truncateFileName(name: string): string {
const maxLength = Platform.isMobile
? this.settings.mobileNotificationMaxLength
: this.settings.desktopNotificationMaxLength;
return name.length > maxLength
? `${name.slice(0, maxLength)}`
: name;
}
private updateAllNoteInstances(notePath: string) {
this.app.workspace
.getLeavesOfType("markdown")
.filter((leaf) => this.isSameNote(leaf, notePath))
.forEach((leaf) => this.updateLeafMode(leaf));
}
private isSameNote(leaf: WorkspaceLeaf, notePath: string): boolean {
const view = leaf.view;
return view instanceof MarkdownView && view.file?.path === notePath;
}
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)
? "preview"
: "source";
if (leaf.getViewState().state?.mode !== targetMode) {
leaf.setViewState({
...leaf.getViewState(),
state: { ...leaf.getViewState().state, mode: targetMode },
});
}
}
private async loadSettings() {
const loaded = await this.loadData();
if (loaded) {
this.settings.lockedNotes = new Set(loaded.lockedNotes);
}
}
private async saveSettings() {
await this.saveData({
lockedNotes: Array.from(this.settings.lockedNotes),
});
}
}

View file

@ -1,10 +1,10 @@
{ {
"id": "note-locker", "id": "note-locker",
"name": "Note Locker", "name": "Note Locker",
"version": "1.0.0", "version": "1.3.1",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Note Locker allows you to \"lock\" a note so that it opens in preview mode by default", "description": "Lock notes to open in preview mode by default.",
"author": "Felvesthe", "author": "Felvesthe",
"authorUrl": "https://github.com/Felvesthe", "authorUrl": "https://github.com/Felvesthe",
"isDesktopOnly": false "isDesktopOnly": false
} }

2397
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"name": "note-locker", "name": "note-locker",
"version": "1.0.0", "version": "1.3.0",
"description": "Note Locker allows you to \"lock\" a note so that it opens in preview mode by default", "description": "Lock notes to open in preview mode by default.",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
@ -21,4 +21,4 @@
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "typescript": "4.7.4"
} }
} }

621
src/main.ts Normal file
View file

@ -0,0 +1,621 @@
import {
MarkdownView,
Menu,
Notice,
Platform,
Plugin,
TFile,
WorkspaceLeaf,
TAbstractFile,
TFolder,
setIcon,
} from "obsidian";
import { NoteLockerSettings, DEFAULT_SETTINGS } from "./models/types";
import { FileExplorerUI } from "./ui/fileExplorer";
import { StatusBarUI } from "./ui/statusBar";
import { NoteLockerSettingTab } from "./settings";
import { StrictUnlockModal } from "./ui/strictUnlockModal";
export default class NoteLockerPlugin extends Plugin {
settings: NoteLockerSettings = DEFAULT_SETTINGS;
fileExplorerUI: FileExplorerUI;
statusBarUI: StatusBarUI;
async onload() {
await this.loadSettings();
this.fileExplorerUI = new FileExplorerUI(this);
this.statusBarUI = new StatusBarUI(this);
this.registerEventHandlers();
this.initializeExistingLeaves();
if (this.settings.showStatusBarButton) {
this.statusBarUI.createStatusBarItem();
}
if (this.settings.showFileExplorerIcons) {
this.fileExplorerUI.addFileExplorerIconStyling();
}
this.addSettingTab(new NoteLockerSettingTab(this.app, this));
// hotkey
this.addCommand({
id: 'toggle-note-lock',
name: 'Toggle lock for current note',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (file && file.extension === 'md') {
if (!checking) {
if (this.settings.strictLockedNotes.has(file.path)) {
new StrictUnlockModal(this.app, () => {
this.toggleStrictLock(file.path);
}).open();
} else {
this.toggleNoteLock(file.path);
}
}
return true;
}
return false;
},
});
this.addCommand({
id: 'switch-to-edit-mode-intercept',
name: 'Switch to edit mode (strict lock check)',
hotkeys: [{ modifiers: ["Mod"], key: "e" }],
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (file && file.extension === 'md') {
if (this.settings.strictLockedNotes.has(file.path)) {
if (!checking) {
new StrictUnlockModal(this.app, () => {
this.toggleStrictLock(file.path);
}).open();
}
return true;
}
}
return false;
},
});
}
onunload() {
const styleEl = document.getElementById('note-locker-styles');
if (styleEl) styleEl.remove();
this.fileExplorerUI.cleanup();
this.statusBarUI.removeStatusBarItem();
}
private registerEventHandlers() {
this.registerEvent(
this.app.workspace.on("file-menu", (menu, file) =>
this.addLockMenuItem(menu, file.path)
)
);
this.registerEvent(
this.app.workspace.on("files-menu", (menu, files) =>
this.addBulkLockMenuItem(menu, files)
)
);
this.registerEvent(
this.app.workspace.on("editor-menu", (menu, _, view) =>
view.file && this.addLockMenuItem(menu, view.file.path)
)
);
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
this.updateLeafMode(leaf, true);
this.statusBarUI.updateStatusBarButton();
})
);
this.registerEvent(
this.app.vault.on("rename", async (file, oldPath) => {
let settingsChanged = false;
if (this.settings.lockedNotes.delete(oldPath)) {
if (!this.settings.lockedNotes.has(file.path)) {
this.settings.lockedNotes.add(file.path);
settingsChanged = true;
}
}
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();
}
})
);
this.registerEvent(
this.app.workspace.on("file-open", () => {
this.statusBarUI.updateStatusBarButton();
this.fileExplorerUI.updateFileExplorerIcons();
this.updateLeafMode(this.app.workspace.getLeaf(false), true);
})
);
this.registerEvent(
this.app.workspace.on("layout-change", () => {
this.fileExplorerUI.updateFileExplorerIcons();
this.app.workspace.iterateAllLeaves((leaf) => this.updateLeafMode(leaf));
})
);
}
private initializeExistingLeaves() {
this.app.workspace
.getLeavesOfType("markdown")
.forEach((leaf) => this.updateLeafMode(leaf, true));
}
private addBulkLockMenuItem(menu: Menu, files: TAbstractFile[]) {
const validItems = files.filter(f =>
(f instanceof TFile && f.extension === 'md') ||
(f instanceof TFolder)
);
if (validItems.length < 2) return;
let allLocked = true;
let allUnlocked = true;
let allStrictUnlocked = true;
for (const item of validItems) {
const isLocked = this.isPathLocked(item.path);
const isStrictLocked = this.settings.strictLockedNotes.has(item.path);
if (isLocked) {
allUnlocked = false;
} else {
allLocked = false;
}
if (isStrictLocked) {
allStrictUnlocked = false;
}
}
if (allLocked) {
menu.addItem((item) =>
item
.setTitle(`Unlock ${validItems.length} items`)
.setIcon("unlock")
.onClick(() => {
if (!allStrictUnlocked) {
new StrictUnlockModal(
this.app,
() => this.toggleBulkLock(validItems, false),
"Strictly locked items",
"At least one of the selected items is strictly locked."
).open();
} else {
this.toggleBulkLock(validItems, false);
}
})
);
} else if (allUnlocked) {
menu.addItem((item) =>
item
.setTitle(`Lock ${validItems.length} items`)
.setIcon("lock")
.onClick(() => this.toggleBulkLock(validItems, true))
);
}
const hasFiles = validItems.some(f => f instanceof TFile);
if (allStrictUnlocked && hasFiles) {
if (allUnlocked || allLocked) {
menu.addItem((item) =>
item
.setTitle(`Strict lock ${validItems.length} items`)
.setIcon("lock")
.onClick(() => this.toggleBulkStrictLock(validItems, true))
);
}
}
}
private async toggleBulkLock(files: TAbstractFile[], shouldLock: boolean) {
for (const file of files) {
if (file instanceof TFile) {
if (shouldLock) {
this.settings.lockedNotes.add(file.path);
} else {
this.settings.lockedNotes.delete(file.path);
this.settings.strictLockedNotes.delete(file.path);
}
} else if (file instanceof TFolder) {
if (shouldLock) {
this.settings.lockedFolders.add(file.path);
} else {
this.settings.lockedFolders.delete(file.path);
}
}
}
await this.saveSettings();
this.fileExplorerUI.updateFileExplorerIcons();
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof MarkdownView && leaf.view.file) {
const path = leaf.view.file.path;
if (files.some(f => f.path === path || (f instanceof TFolder && path.startsWith(f.path + '/')))) {
this.updateLeafMode(leaf);
}
}
});
}
private async toggleBulkStrictLock(files: TAbstractFile[], shouldLock: boolean) {
for (const file of files) {
if (file instanceof TFile) {
if (shouldLock) {
this.settings.strictLockedNotes.add(file.path);
} else {
this.settings.strictLockedNotes.delete(file.path);
}
}
}
await this.saveSettings();
this.fileExplorerUI.updateFileExplorerIcons();
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof MarkdownView && leaf.view.file) {
const path = leaf.view.file.path;
if (files.some(f => f.path === path)) {
this.updateLeafMode(leaf);
}
}
});
}
private addLockMenuItem(menu: Menu, path: string) {
if (this.isParentFolderLocked(path)) {
return;
}
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile && file.extension === 'md') {
const isLocked = this.settings.lockedNotes.has(path);
const isStrictLocked = this.settings.strictLockedNotes.has(path);
if (!isStrictLocked) {
menu.addItem((item) =>
item
.setTitle(isLocked ? "Unlock" : "Lock")
.setIcon(isLocked ? "unlock" : "lock")
.onClick(() => this.toggleNoteLock(path))
);
}
if (!isLocked) {
if (!isStrictLocked) {
menu.addItem((item) =>
item
.setTitle("Strict lock")
.setIcon("lock")
.onClick(() => this.toggleStrictLock(path))
);
} else {
menu.addItem((item) =>
item
.setTitle("Strict unlock")
.setIcon("unlock")
.onClick(() => {
new StrictUnlockModal(this.app, () => {
this.toggleStrictLock(path);
}).open();
})
);
}
}
} 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) {
const isLocked = this.settings.lockedNotes.has(notePath);
isLocked
? this.settings.lockedNotes.delete(notePath)
: this.settings.lockedNotes.add(notePath);
await this.saveSettings();
const file = this.app.vault.getAbstractFileByPath(notePath);
if (!file) {
new Notice("Error: Note not found");
return;
}
if (this.settings.showNotifications) {
const fileName = file instanceof TFile ? file.basename :
notePath.split('/').pop()?.replace(/\..+$/, '') || notePath;
const displayName = this.truncateFileName(fileName);
new Notice(`${isLocked ? '🔓 Unlocked' : '🔒 Locked'}: ${displayName}`);
}
this.updateAllNoteInstances(notePath);
this.statusBarUI.updateStatusBarButton();
this.fileExplorerUI.updateFileExplorerIcons();
}
async toggleStrictLock(notePath: string) {
const isStrictLocked = this.settings.strictLockedNotes.has(notePath);
isStrictLocked
? this.settings.strictLockedNotes.delete(notePath)
: this.settings.strictLockedNotes.add(notePath);
await this.saveSettings();
const file = this.app.vault.getAbstractFileByPath(notePath);
if (!file) {
new Notice("Error: Note not found");
return;
}
if (this.settings.showNotifications) {
const fileName = file instanceof TFile ? file.basename :
notePath.split('/').pop()?.replace(/\..+$/, '') || notePath;
const displayName = this.truncateFileName(fileName);
new Notice(`${isStrictLocked ? '🔓 Strictly unlocked' : '🔒 Strictly locked'}: ${displayName}`);
}
this.updateAllNoteInstances(notePath);
this.statusBarUI.updateStatusBarButton();
this.fileExplorerUI.updateFileExplorerIcons();
}
private truncateFileName(name: string): string {
const maxLength = Platform.isMobile
? this.settings.mobileNotificationMaxLength
: this.settings.desktopNotificationMaxLength;
return name.length > maxLength
? `${name.slice(0, maxLength)}`
: name;
}
private updateAllNoteInstances(notePath: string) {
this.app.workspace
.getLeavesOfType("markdown")
.filter((leaf) => this.isSameNote(leaf, notePath))
.forEach((leaf) => this.updateLeafMode(leaf));
}
private isSameNote(leaf: WorkspaceLeaf, notePath: string): boolean {
const view = leaf.view;
return view instanceof MarkdownView && view.file?.path === notePath;
}
public isPathLocked(path: string): boolean {
if (this.settings.strictLockedNotes.has(path)) return true;
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;
}
public updateLeafMode(leaf: WorkspaceLeaf | null, fromNavigation: boolean = false) {
if (!leaf || !(leaf.view instanceof MarkdownView)) return;
const { view } = leaf;
const path = view.file?.path;
if (!path) return;
const isStrictLocked = this.settings.strictLockedNotes.has(path);
const isLocked = this.isPathLocked(path);
if (isStrictLocked) {
const state = leaf.getViewState();
if (state.state?.mode === 'source') {
leaf.setViewState({
...state,
state: { ...state.state, mode: 'preview' },
});
return;
}
} else if (isLocked) {
const state = leaf.getViewState();
if ((fromNavigation || this.settings.preventEditInLockedNotes) && state.state?.mode === 'source') {
leaf.setViewState({
...state,
state: { ...state.state, mode: 'preview' },
});
}
}
if (isStrictLocked) {
this.hideEditButtons(view);
this.addStrictLockButton(view, path);
} else if (isLocked && this.settings.preventEditInLockedNotes) {
this.hideEditButtons(view);
this.removeStrictLockButton(view);
} else {
this.showEditButtons(view);
this.removeStrictLockButton(view);
}
}
private hideEditButtons(view: MarkdownView) {
const container = view.containerEl;
const allActions = container.querySelectorAll('.view-action');
allActions.forEach(btn => {
if (btn instanceof HTMLElement) {
const ariaLabel = btn.getAttribute('aria-label') || '';
const hasPencilIcon = !!btn.querySelector('.lucide-pencil');
if (hasPencilIcon || ariaLabel.includes('Edit') || ariaLabel.includes('edit') || ariaLabel.includes('Switch to editing')) {
btn.style.display = 'none';
}
}
});
}
private showEditButtons(view: MarkdownView) {
const container = view.containerEl;
const editBtns = container.querySelectorAll('.view-action');
editBtns.forEach(btn => {
if (btn instanceof HTMLElement) {
if (btn.getAttribute('aria-label')?.includes('Edit') ||
btn.getAttribute('aria-label')?.includes('edit') ||
btn.querySelector('.lucide-pencil')) {
btn.style.display = '';
}
}
});
}
private addStrictLockButton(view: MarkdownView, path: string) {
const container = view.containerEl;
let lockBtn = container.querySelector('.note-locker-strict-btn') as HTMLElement;
if (!lockBtn) {
lockBtn = document.createElement('div');
lockBtn.addClass('view-action', 'clickable-icon', 'note-locker-strict-btn');
lockBtn.setAttribute('aria-label', 'Strictly locked');
setIcon(lockBtn, 'lock-keyhole');
lockBtn.addEventListener('click', () => {
new StrictUnlockModal(this.app, () => {
this.toggleStrictLock(path);
}).open();
});
const actionsContainer = container.querySelector('.view-actions');
if (actionsContainer) {
actionsContainer.prepend(lockBtn);
}
}
}
private removeStrictLockButton(view: MarkdownView) {
const container = view.containerEl;
const lockBtn = container.querySelector('.note-locker-strict-btn');
if (lockBtn) lockBtn.remove();
}
async loadSettings() {
const loaded = await this.loadData();
if (loaded) {
this.settings = {
...DEFAULT_SETTINGS,
...loaded,
lockedNotes: new Set(loaded.lockedNotes || []),
lockedFolders: new Set(loaded.lockedFolders || []),
strictLockedNotes: new Set(loaded.strictLockedNotes || [])
};
}
}
async saveSettings() {
await this.saveData({
...this.settings,
lockedNotes: Array.from(this.settings.lockedNotes),
lockedFolders: Array.from(this.settings.lockedFolders),
strictLockedNotes: Array.from(this.settings.strictLockedNotes),
});
}
async updateFileExplorerIconsVisibility(value: boolean) {
this.settings.showFileExplorerIcons = value;
if (value) {
this.fileExplorerUI.addFileExplorerIconStyling();
} else {
this.fileExplorerUI.removeFileExplorerIcons();
}
await this.saveSettings();
}
async updateStatusBarVisibility(value: boolean) {
this.settings.showStatusBarButton = value;
if (value) {
if (!this.statusBarUI.statusBarItemEl) {
this.statusBarUI.createStatusBarItem();
}
} else {
this.statusBarUI.removeStatusBarItem();
}
await this.saveSettings();
}
}

23
src/models/types.ts Normal file
View file

@ -0,0 +1,23 @@
export interface NoteLockerSettings {
lockedNotes: Set<string>;
lockedFolders: Set<string>;
strictLockedNotes: Set<string>;
mobileNotificationMaxLength: number;
desktopNotificationMaxLength: number;
showFileExplorerIcons: boolean;
showStatusBarButton: boolean;
showNotifications: boolean;
preventEditInLockedNotes: boolean;
}
export const DEFAULT_SETTINGS: NoteLockerSettings = {
lockedNotes: new Set(),
lockedFolders: new Set(),
strictLockedNotes: new Set(),
mobileNotificationMaxLength: 18,
desktopNotificationMaxLength: 22,
showFileExplorerIcons: true,
showStatusBarButton: true,
showNotifications: true,
preventEditInLockedNotes: false
};

118
src/settings.ts Normal file
View file

@ -0,0 +1,118 @@
import { App, Platform, PluginSettingTab, Setting, MarkdownView } from "obsidian";
import type NoteLockerPlugin from "./main";
export class NoteLockerSettingTab extends PluginSettingTab {
plugin: NoteLockerPlugin;
constructor(app: App, plugin: NoteLockerPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Show locked notes in file explorer')
.setDesc('Display a lock icon next to locked notes in the file explorer')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showFileExplorerIcons)
.onChange(async (value) => {
await this.plugin.updateFileExplorerIconsVisibility(value);
}));
new Setting(containerEl)
.setName('Show status bar button')
.setDesc('Display a lock/unlock button in the status bar for the current note')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showStatusBarButton)
.onChange(async (value) => {
await this.plugin.updateStatusBarVisibility(value);
}));
new Setting(containerEl)
.setName('Show notifications')
.setDesc('Show notifications when locking or unlocking notes')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showNotifications)
.onChange(async (value) => {
this.plugin.settings.showNotifications = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Prevent editing in locked notes')
.setDesc('If enabled, normally locked notes will be read-only. If disabled, you can switch to edit mode.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.preventEditInLockedNotes)
.onChange(async (value) => {
this.plugin.settings.preventEditInLockedNotes = value;
await this.plugin.saveSettings();
this.plugin.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof MarkdownView && leaf.view.file) {
this.plugin.updateLeafMode(leaf);
}
});
}));
const mobileNotificationSetting = new Setting(containerEl)
.setName('Mobile notification max length')
.setDesc('Maximum length of file names in notifications on mobile devices')
.addText(text => text
.setPlaceholder('18')
.setValue(String(this.plugin.settings.mobileNotificationMaxLength))
.onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.mobileNotificationMaxLength = numValue;
await this.plugin.saveSettings();
}
}));
const desktopNotificationSetting = new Setting(containerEl)
.setName('Desktop notification max length')
.setDesc('Maximum length of file names in notifications on desktop')
.addText(text => text
.setPlaceholder('22')
.setValue(String(this.plugin.settings.desktopNotificationMaxLength))
.onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.desktopNotificationMaxLength = numValue;
await this.plugin.saveSettings();
}
}));
if (Platform.isMobile) {
desktopNotificationSetting.settingEl.style.display = 'none';
} else {
mobileNotificationSetting.settingEl.style.display = 'none';
}
const hotkeyInfo = containerEl.createEl('p', {
text: 'You can set a hotkey for locking/unlocking notes in Settings → Hotkeys → Toggle Lock for current note.'
});
hotkeyInfo.style.fontStyle = 'italic';
containerEl.createEl('h3', { text: 'Locked notes & folders statistics' });
const lockedNotesCount = this.plugin.settings.lockedNotes.size;
const lockedFoldersCount = this.plugin.settings.lockedFolders.size;
const strictLockedNotesCount = this.plugin.settings.strictLockedNotes.size;
containerEl.createEl('p', {
text: 'You currently have:'
});
containerEl
.createEl('ul')
.createEl('li', {
text: `Locked notes: ${lockedNotesCount}`
})
.createEl('li', {
text: `Locked folders: ${lockedFoldersCount}`
})
.createEl('li', {
text: `Strictly locked notes: ${strictLockedNotesCount}`
});
}
}

237
src/ui/fileExplorer.ts Normal file
View file

@ -0,0 +1,237 @@
import { setIcon } from "obsidian";
import type NoteLockerPlugin from "../main";
export class FileExplorerUI {
private plugin: NoteLockerPlugin;
private folderObserver: MutationObserver | null = null;
private updateDebounceTimeout: number | null = null;
constructor(plugin: NoteLockerPlugin) {
this.plugin = plugin;
}
public addFileExplorerIconStyling(): void {
const styleEl = document.createElement('style');
styleEl.id = 'note-locker-styles';
styleEl.textContent = `
.note-locker-icon {
display: flex;
justify-content: center;
align-items: center;
margin-left: 6px;
width: 14px;
height: 14px;
color: var(--text-accent);
opacity: 0.8;
}
.nav-file-title, .nav-folder-title {
display: flex;
align-items: center;
}
.nav-file-title-content, .nav-folder-title-content {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
`;
document.head.appendChild(styleEl);
this.plugin.app.workspace.onLayoutReady(() => {
this.updateFileExplorerIcons();
this.setupFolderObserver();
});
}
private setupFolderObserver(): void {
if (this.folderObserver) {
this.folderObserver.disconnect();
this.folderObserver = null;
}
const fileExplorer = document.querySelector('.workspace-split.mod-left-split .nav-files-container');
if (!fileExplorer) return;
this.folderObserver = new MutationObserver((mutations) => {
let shouldUpdate = false;
for (const mutation of mutations) {
if (mutation.target instanceof HTMLElement &&
(mutation.target.classList.contains('note-locker-icon') ||
mutation.target.closest('.note-locker-icon'))) {
continue;
}
if (mutation.type === 'childList') {
const target = mutation.target as HTMLElement;
const isTitle = target.classList.contains('nav-file-title') || target.classList.contains('nav-folder-title');
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', '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 iconName: string | null = null;
if (item.classList.contains('nav-file')) {
if (this.plugin.settings.strictLockedNotes.has(path)) {
iconName = 'lock-keyhole';
} else if (this.plugin.settings.lockedNotes.has(path)) {
iconName = 'lock';
}
} else if (item.classList.contains('nav-folder')) {
if (this.plugin.settings.lockedFolders.has(path)) {
iconName = 'lock';
}
}
this.updateIconState(titleEl, iconName);
}
public updateFileExplorerIcons(): void {
if (!this.plugin.settings.showFileExplorerIcons) {
this.removeFileExplorerIcons();
return;
}
// 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) return;
let iconName: string | null = null;
if (this.plugin.settings.strictLockedNotes.has(filePath)) {
iconName = 'lock-keyhole';
} else if (this.plugin.settings.lockedNotes.has(filePath)) {
iconName = 'lock';
}
this.updateIconState(titleEl, iconName);
});
// 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;
let iconName: string | null = null;
if (this.plugin.settings.lockedFolders.has(folderPath)) {
iconName = 'lock';
}
this.updateIconState(titleEl, iconName);
});
}
private updateIconState(targetEl: Element, iconName: string | null): void {
const existingIcon = targetEl.querySelector('.note-locker-icon');
if (iconName) {
if (!existingIcon) {
const iconEl = document.createElement('div');
iconEl.addClass('note-locker-icon');
setIcon(iconEl, iconName);
targetEl.appendChild(iconEl);
} else {
setIcon(existingIcon as HTMLElement, iconName);
}
} else {
if (existingIcon) {
existingIcon.remove();
}
}
}
public removeFileExplorerIcons(): void {
document.querySelectorAll('.note-locker-icon').forEach(el => el.remove());
}
public cleanup(): void {
if (this.updateDebounceTimeout !== null) {
window.clearTimeout(this.updateDebounceTimeout);
this.updateDebounceTimeout = null;
}
if (this.folderObserver) {
this.folderObserver.disconnect();
this.folderObserver = null;
}
this.removeFileExplorerIcons();
}
}

88
src/ui/statusBar.ts Normal file
View file

@ -0,0 +1,88 @@
import { setIcon } from "obsidian";
import type NoteLockerPlugin from "../main";
import { StrictUnlockModal } from "./strictUnlockModal";
export class StatusBarUI {
private plugin: NoteLockerPlugin;
public statusBarItemEl: HTMLElement | null = null;
constructor(plugin: NoteLockerPlugin) {
this.plugin = plugin;
}
public createStatusBarItem(): void {
if (!this.plugin.settings.showStatusBarButton) return;
this.statusBarItemEl = this.plugin.addStatusBarItem();
if (this.statusBarItemEl) {
this.statusBarItemEl.addClass('note-locker-status');
this.statusBarItemEl.addEventListener('click', () => {
const activeFile = this.plugin.app.workspace.getActiveFile();
if (activeFile) {
if (this.plugin.settings.strictLockedNotes.has(activeFile.path)) {
new StrictUnlockModal(this.plugin.app, () => {
this.plugin.toggleStrictLock(activeFile.path);
}).open();
} else {
this.plugin.toggleNoteLock(activeFile.path);
}
}
});
this.updateStatusBarButton();
}
}
public updateStatusBarButton(): void {
if (!this.plugin.settings.showStatusBarButton || !this.statusBarItemEl) return;
const isLocked = this.isCurrentNoteActive();
this.statusBarItemEl.empty();
const style = document.createElement('style');
style.textContent = `
.note-locker-status .locker-icon {
margin-right: 6px;
}
`;
document.head.appendChild(style);
if (this.plugin.app.workspace.getActiveFile()) {
const activeFile = this.plugin.app.workspace.getActiveFile();
const path = activeFile ? activeFile.path : '';
const isStrictLocked = this.plugin.settings.strictLockedNotes.has(path);
const isLocked = this.isCurrentNoteActive();
const iconSpan = this.statusBarItemEl.createSpan({ cls: 'locker-icon' });
if (isStrictLocked) {
setIcon(iconSpan, 'lock-keyhole');
this.statusBarItemEl.createSpan({ text: ' Strictly locked' });
this.statusBarItemEl.setAttribute('aria-label', 'Strictly locked. Click to unlock.');
} else {
setIcon(iconSpan, isLocked ? 'lock' : 'unlock');
this.statusBarItemEl.createSpan({
text: isLocked ? ' Locked' : ' Unlocked'
});
this.statusBarItemEl.setAttribute('aria-label',
isLocked ? 'Click to unlock this note' : 'Click to lock this note');
}
this.statusBarItemEl.style.cursor = 'pointer';
} else {
this.statusBarItemEl.style.cursor = 'default';
}
}
private isCurrentNoteActive(): boolean {
const activeFile = this.plugin.app.workspace.getActiveFile();
return activeFile ? this.plugin.settings.lockedNotes.has(activeFile.path) : false;
}
public removeStatusBarItem(): void {
if (this.statusBarItemEl) {
this.statusBarItemEl.detach();
this.statusBarItemEl = null;
}
}
}

View file

@ -0,0 +1,50 @@
import { Modal, App, Setting } from "obsidian";
export class StrictUnlockModal extends Modal {
private onUnlock: () => void;
private title: string;
private message: string;
constructor(
app: App,
onUnlock: () => void,
title: string = "Strictly locked note",
message: string = "This note is strictly locked to prevent accidental editing.") {
super(app);
this.onUnlock = onUnlock;
this.title = title;
this.message = message;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: this.title });
contentEl.createEl("p", { text: this.message });
contentEl.createEl("p", { text: "Are you sure you want to unlock?" });
new Setting(contentEl)
.addButton((btn) =>
btn
.setButtonText("Unlock")
.setCta()
.onClick(() => {
this.onUnlock();
this.close();
})
)
.addButton((btn) =>
btn
.setButtonText("Cancel")
.onClick(() => {
this.close();
})
);
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -19,6 +19,6 @@
] ]
}, },
"include": [ "include": [
"**/*.ts" "src/**/*.ts"
] ]
} }

View file

@ -3,12 +3,15 @@ import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version; const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version // read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest; const { minAppVersion } = manifest;
manifest.version = targetVersion; manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json // update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8")); // but only if the target version is not already in versions.json
versions[targetVersion] = minAppVersion; const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}