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:
1. Check for conflicts with other plugins
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: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"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",
"name": "Note Locker",
"version": "1.0.0",
"version": "1.3.1",
"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",
"authorUrl": "https://github.com/Felvesthe",
"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",
"version": "1.0.0",
"description": "Note Locker allows you to \"lock\" a note so that it opens in preview mode by default",
"version": "1.3.0",
"description": "Lock notes to open in preview mode by default.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
@ -21,4 +21,4 @@
"tslib": "2.4.0",
"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": [
"**/*.ts"
"src/**/*.ts"
]
}

View file

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