From 3aa345026a9bc10f9231fddf52d6a72a1912ed4a Mon Sep 17 00:00:00 2001 From: Keonsoon Hwang Date: Tue, 28 Apr 2026 05:08:01 +0900 Subject: [PATCH] feat: add Snowflake ID generator and multi-folder auto-generation scope (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Snowflake ID generator and multi-folder auto-generation scope - Add Snowflake ID generator (64-bit, time-sortable, distributed) - Uses Unix timestamp directly (no custom epoch) - 41-bit timestamp + 10-bit node ID + 12-bit sequence - Auto-detect node ID from MAC address via os.networkInterfaces() - Falls back to a random persistent value on mobile - Node ID manually overridable in settings - Support multiple target folders for auto-generation scope - Replace single autoGenerationFolder string with autoGenerationFolders array - Automatic migration of existing single-folder setting to array - New FolderSelectionModal with search and add/remove UI - Bump build target to ES2020 for BigInt support - Fix Automatic uid generation description rendering (use setDesc on heading) Co-Authored-By: Claude Sonnet 4.6 * Tighten Snowflake init and bound the per-ms spin-wait src/main.ts onload: - Drop the redundant outer `uidGenerator === 'snowflake' || ...` guard. The inner branch only ran when `snowflakeAutoDetectNodeId` was true, so the outer disjunction was dead. Restrict initialization to the case where the user has actually selected Snowflake. - Skip `saveSettings()` when `detectNodeId()` returns the value already stored. The previous code wrote settings to disk on every plugin load, even when nothing had changed. src/uidUtils.ts: - Drop the inferrable `: bigint` annotations on the module-level Snowflake state (lint). - Bound the sequence-overflow spin-wait to a few ms. The previous `while (timestamp <= snowflakeLastTimestamp)` had no exit if the system clock was stuck or jumped backwards (NTP, suspend/resume). - Treat a backwards clock jump explicitly: bump the stored timestamp forward by one ms instead of regressing or spinning. IDs stay monotonic across the jump; nodeID/sequence layout is unchanged. - Export a test-only `_resetSnowflakeState` so unit tests can reset module state between cases. * Update test fakes and cover Snowflake + multi-folder scope The previous test suite was authored against the single-folder `autoGenerationFolder` setting. The PR replaces that with the `autoGenerationFolders` array, which broke 11 existing tests (the array was undefined in the fake's default settings). tests/fakes/app.ts: - Initialize `autoGenerationFolders: []` and the new Snowflake fields in the fake's DEFAULT_SETTINGS so plugin code that reads them in tests doesn't crash on `undefined`. - `snowflakeAutoDetectNodeId` defaults to `false` in the fake to keep tests deterministic. src/commands.test.ts: - Rewrite every `autoGenerationFolder: 'X'` case to use `autoGenerationFolders: ['X']`. The legacy field is now only consulted by the one-shot migration in loadSettings. - Add a `multi-folder scope` block that verifies in/out-of-scope decisions across multiple configured folders, nested subfolders, whitespace handling, and blank entries. src/uidUtils.test.ts: - Add a `Snowflake ID generator` block: numeric-string output, node-ID encoding (including >1023 clamp), per-ms sequence increment, sequence reset on next ms, monotonic output across many calls in one ms, and recovery from a backwards clock jump. - Reset module state between cases via `_resetSnowflakeState`. * Document Snowflake generator and multi-folder auto-generation scope - Add Snowflake to the algorithm overview, noting the bit layout and the desktop-MAC / mobile-random node ID derivation. - Update the Auto-Generation section to reference Specific Folder(s), the new "Manage folders" modal, and the automatic migration of the legacy single-folder setting. - Add the new Snowflake settings (Auto-detect Node ID, Node ID) to the Settings reference. - Update the "Auto-Assign IDs to New Notes in Inbox" example for the new modal-based folder picker. - List src/ui/FolderSelectionModal.ts in the Code Structure section. * Show Machine and Custom Node IDs as separate fields Replace the single Node ID input + auto-detect toggle with two distinct fields, so users can see at a glance whether the value used for generated IDs is the machine default or an explicit override: - Machine Node ID (read-only): the MAC-derived value on desktop, or the random persistent fallback on mobile. Always visible and always current — re-resolved on plugin load and on every settings render via `resolveAutoDetectedNodeId`. - Custom Node ID (text input): optional override. When set, it takes precedence over the machine value at generation time. Clearing the field falls back to the machine value. Settings model: - Add `snowflakeNodeIdOverride: number | null`. - Remove the `snowflakeAutoDetectNodeId` toggle from the active model; it lives on as an optional legacy field for one-shot migration. - One-shot migration: a previous build's `snowflakeAutoDetectNodeId === false` with a non-zero `snowflakeNodeId` is preserved as a Custom Node ID, then the legacy field is deleted. Generator: `generateRawUID` now resolves `override ?? snowflakeNodeId` per call, so changes to either field take effect immediately. Tests: add coverage for both precedence directions (override wins; null override falls through to the machine value). Descriptions in the settings UI flip between "currently used" and "currently NOT used / overridden below" so the precedence is obvious without reading the README. * Cover the Snowflake auto-detect resolution paths `resolveAutoDetectedNodeId` is the entry point used by main.ts onload and the settings UI to keep the cached machine Node ID in sync with the hardware. It had zero unit coverage. To make the no-MAC (mobile) path testable without depending on the test machine, accept an optional `detect` parameter that defaults to `detectNodeId`. Production callers don't pass it. New tests cover: - Detected differs from stored → returns the detected value. - Detected matches stored → returns null (no churn). - Mobile + stored 0 → picks a random 10-bit value. - Mobile + stored already set → returns null (preserves the random pick). - Math.random() at extreme values stays in 0–1023 (Math.floor guard). Plus a smoke test for `detectNodeId`: never throws on the host running tests, returns either null or a number in 0–1023, and is deterministic across calls on the same machine. These exercise the cross-platform safety we rely on for Windows, Linux, macOS desktop and the iOS/Android Obsidian Mobile fallback. * Prevent the mobile random Node ID from colliding with the unset sentinel The previous code picked a random Node ID in 0..1023 when no MAC was available. `stored === 0` is also the sentinel for "not yet picked", so a 1-in-1024 unlucky roll landed on 0, made the saved settings look unset on next load, and triggered a re-roll. The user would silently get a new node ID on every restart, breaking ID stability across sessions and undermining the "random persistent" guarantee Snowflake relies on for mobile. Restrict the random fallback to 1..1023. The valid Snowflake node-ID range is unchanged (manual/MAC-derived values can still be 0); only the random pick is excluded from 0. Losing one out of 1024 possible values in the random pool is negligible. Add tests: - The mobile path never returns 0 (lock the sentinel-collision fix in). - A two-load simulation: first call picks a value, second call returns null — i.e. the saved value is genuinely persistent. --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Netajam --- README.md | 16 ++- esbuild.config.mjs | 2 +- src/commands.test.ts | 72 +++++++++--- src/commands.ts | 27 ++++- src/main.ts | 47 +++++++- src/settings.ts | 121 +++++++++++++++++--- src/ui/FolderSelectionModal.ts | 97 ++++++++++++++++ src/uidUtils.test.ts | 199 ++++++++++++++++++++++++++++++++- src/uidUtils.ts | 152 +++++++++++++++++++++++-- tests/fakes/app.ts | 3 + tsconfig.json | 5 +- 11 files changed, 678 insertions(+), 63 deletions(-) create mode 100644 src/ui/FolderSelectionModal.ts diff --git a/README.md b/README.md index 77f055f..9f37c8c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Overview -The UID Generator plugin for Obsidian provides tools to create and manage unique identifiers (UIDs) for your notes directly within their frontmatter metadata. It supports multiple generator algorithms — **UUID** (v4), **NanoID** (customizable length, alphabet, and separators), and **ULID** (lexicographically sortable) — along with manual and automatic UID generation, customization of the metadata key and copy formats, and bulk operations within folders. This helps in creating stable, unique references for your notes, useful for linking, scripting, or external systems. +The UID Generator plugin for Obsidian provides tools to create and manage unique identifiers (UIDs) for your notes directly within their frontmatter metadata. It supports multiple generator algorithms — **UUID** (v4), **NanoID** (customizable length, alphabet, and separators), **ULID** (lexicographically sortable), and **Snowflake** (64-bit time-sortable distributed ID) — along with manual and automatic UID generation, customization of the metadata key and copy formats, and bulk operations within folders. This helps in creating stable, unique references for your notes, useful for linking, scripting, or external systems. ## Features @@ -10,6 +10,7 @@ The UID Generator plugin for Obsidian provides tools to create and manage unique * **UUID** (v4) — Standard 36-character universally unique identifier. * **NanoID** — Customizable length, alphabet, and optional separator characters injected at specific positions. * **ULID** — 26-character, lexicographically sortable identifier that encodes creation time (useful for chronological ordering). + * **Snowflake** — 64-bit time-sortable numeric ID (41-bit Unix timestamp + 10-bit node ID + 12-bit per-ms sequence). Node ID is auto-derived from the machine's MAC address on desktop and falls back to a random persistent value on mobile; both can be overridden manually. * **Duplicate Detection:** An in-memory cache of existing UIDs ensures newly generated IDs are unique, with automatic retry on collision. * **Generate/Update UID:** Manually generate a new UID for the current note, optionally overwriting any existing UID under the configured key. * **Create UID If Missing:** Manually generate a UID for the current note *only* if one doesn't already exist. @@ -17,7 +18,7 @@ The UID Generator plugin for Obsidian provides tools to create and manage unique * **Copy UID:** Copy the UID of the current note to the clipboard. * **Copy title + UID:** Copy the title and UID of the current note (or multiple selected notes) to the clipboard, using a customizable format. * **Automatic UID Generation:** Automatically add a UID to notes upon creation or opening if they lack one. - * Configurable scope (entire vault or specific folder). + * Configurable scope (entire vault or one or more specific folders). * Ability to exclude specific folders. * Never overwrites existing UIDs during automatic generation. * **Clear UIDs in Folder:** Remove all UIDs (using the configured key) from notes within a specified folder and its subfolders, with confirmation. @@ -71,14 +72,16 @@ Access the plugin settings from Obsidian Settings -> Community Plugins -> UID Ge * **General:** * **UID Metadata Key:** Set the frontmatter key name used for storing UIDs (default: `uid`). Avoid spaces. * **UID Generator Type:** - * **Generator Algorithm:** Choose between `UUID`, `NanoID`, or `ULID`. + * **Generator Algorithm:** Choose between `UUID`, `NanoID`, `ULID`, or `Snowflake`. * **NanoID Length:** (NanoID only) Length of the generated ID, excluding separators. Min 4, max 128. (Default: 21) * **NanoID Alphabet:** (NanoID only) Characters used for ID generation. Must have at least 2 unique characters. (Default: `0-9A-Za-z`) * **NanoID Separator Groups:** (NanoID only) Inject characters at specific positions in the generated ID. Positions can be negative (count from end). Multiple groups supported. + * **Machine Node ID:** (Snowflake only) Read-only display of the 10-bit node ID derived from this machine — MAC-hashed on desktop, random persistent on mobile. Used for generated IDs unless a Custom Node ID is set below. + * **Custom Node ID:** (Snowflake only) Optional override (0–1023). When set, takes precedence over the Machine Node ID. Leave empty to use the machine value. * **Automatic UID Generation:** * **Enable Automatic UID Generation:** Toggle the automatic creation of UIDs on/off. - * **Generation Scope:** Choose `Entire Vault` or `Specific Folder`. - * **Target Folder for Auto-Generation:** (Visible if Scope is 'Specific Folder') Enter the path to the folder where auto-generation should occur. Uses folder path suggestions. + * **Generation Scope:** Choose `Entire Vault` or `Specific Folder(s)`. + * **Target Folders for Auto-Generation:** (Visible if Scope is 'Specific Folder(s)') Click "Manage folders" to open a modal where you can search, add, or remove folders that should be in scope for auto-generation. Notes in any of the listed folders (including subfolders) are eligible. Settings created with previous versions of the plugin are migrated automatically. * **Excluded Folders:** Click "Manage Exclusions" to open a modal where you can search, add, or remove folders that should be ignored by automatic generation. The current list is displayed below the button. * **Copy Format:** * **Format (UID exists):** Define the template for copied text when a UID is present. Use placeholders `{title}`, `{uid}`, `{uidKey}`. (Default: `{title} - {uidKey}: {uid}`) @@ -94,7 +97,7 @@ Access the plugin settings from Obsidian Settings -> Community Plugins -> UID Ge * **Ensure a Note Has a Unique ID:** Open the note, open the command palette, run `UID Generator: Create uid if missing`. * **Link Using UID:** Open a note, run `UID Generator: Copy uid`, paste the UID into another note's link or alias. -* **Auto-Assign IDs to New Notes in Inbox:** Enable Automatic Generation, set Scope to 'Specific Folder', set Target Folder to `Inbox`. +* **Auto-Assign IDs to New Notes in Inbox:** Enable Automatic Generation, set Scope to 'Specific Folder(s)', click "Manage folders" and add `Inbox` (and any other target folders). * **Clean Up Old IDs:** Set 'Folder to clear UIDs from' to `Archives/Old Project`, click 'Clear UIDs Now', confirm. * **Get List of Project Notes with IDs:** Right-click the `Projects/Current Project` folder, select `Copy titles+uids from "Current Project"`. @@ -123,6 +126,7 @@ For developers interested in contributing: * `FolderSuggest.ts` * `ConfirmationModal.ts` * `FolderExclusionModal.ts` + * `FolderSelectionModal.ts` * `src/obsidian.d.ts`: Contains TypeScript declarations for undocumented Obsidian APIs used (like `files-menu`). ## Contributing diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 309f17b..14dce49 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -33,7 +33,7 @@ const context = await esbuild.context({ "@lezer/lr", ...builtins], format: "cjs", - target: "es2018", + target: "es2020", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, diff --git a/src/commands.test.ts b/src/commands.test.ts index 2af7d2b..1fcaabe 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -59,7 +59,7 @@ describe('handleAutoGenerateUid — settings-driven decisions', () => { settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], }, filePath: 'Notes/foo.md', shouldGenerate: true, @@ -69,7 +69,7 @@ describe('handleAutoGenerateUid — settings-driven decisions', () => { settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], }, filePath: 'Notes/sub/deep/foo.md', shouldGenerate: true, @@ -79,17 +79,17 @@ describe('handleAutoGenerateUid — settings-driven decisions', () => { settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], }, filePath: 'OtherFolder/foo.md', shouldGenerate: false, }, { - name: 'scope=folder with empty folder setting → no-op (defensive)', + name: 'scope=folder with empty folders array → no-op (defensive)', settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: '', + autoGenerationFolders: [], }, filePath: 'foo.md', shouldGenerate: false, @@ -129,7 +129,7 @@ describe('handleAutoGenerateUid — settings-driven decisions', () => { settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], autoGenerationExclusions: ['Notes/private'], }, filePath: 'Notes/private/secret.md', @@ -140,7 +140,7 @@ describe('handleAutoGenerateUid — settings-driven decisions', () => { settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], autoGenerationExclusions: ['Notes/private'], }, filePath: 'Notes/public/ok.md', @@ -172,6 +172,52 @@ describe('handleAutoGenerateUid — settings-driven decisions', () => { } }); +describe('handleAutoGenerateUid — multi-folder scope', () => { + async function runOn( + filePath: string, + folders: string[], + ): Promise { + const app = makeFakeApp({ + files: [{ path: filePath }], + settings: { + autoGenerateUid: true, + autoGenerationScope: 'folder', + autoGenerationFolders: folders, + }, + }); + await handleAutoGenerateUid(app.plugin, app.files[0]); + return app.frontmatterByPath.get(filePath)?.uid as string | undefined; + } + + it('generates when the file is in any of the configured folders', async () => { + expect(await runOn('Notes/a.md', ['Notes', 'Journal'])).toMatch(UUID_RE); + expect(await runOn('Journal/b.md', ['Notes', 'Journal'])).toMatch(UUID_RE); + }); + + it('skips files that are in none of the configured folders', async () => { + expect(await runOn('Other/a.md', ['Notes', 'Journal'])).toBeUndefined(); + }); + + it('matches files in nested subfolders of a configured folder', async () => { + expect(await runOn('Notes/sub/deep/a.md', ['Notes'])).toMatch(UUID_RE); + }); + + it('treats an empty folders array as out-of-scope for every file', async () => { + expect(await runOn('Notes/a.md', [])).toBeUndefined(); + expect(await runOn('a.md', [])).toBeUndefined(); + }); + + it('strips surrounding whitespace from folder entries', async () => { + expect(await runOn('Notes/a.md', [' Notes '])).toMatch(UUID_RE); + }); + + it('ignores blank/whitespace-only folder entries instead of matching everything', async () => { + // If an empty string accidentally matched as a prefix, every file would be in scope. + // The guard `normScope && (...)` should keep blank entries inert. + expect(await runOn('Other/a.md', ['', ' '])).toBeUndefined(); + }); +}); + describe('handleAutoGenerateUid — exclusion list edge cases', () => { async function runOn( filePath: string, @@ -247,7 +293,7 @@ describe('settings that effectively exclude every file', () => { settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'NonExistent', + autoGenerationFolders: ['NonExistent'], }, }); @@ -271,7 +317,7 @@ describe('settings that effectively exclude every file', () => { settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], autoGenerationExclusions: ['Notes'], // cancels out the scope }, }); @@ -294,7 +340,7 @@ describe('settings that effectively exclude every file', () => { ], settings: { autoGenerationScope: 'folder', - autoGenerationFolder: 'NonExistent', + autoGenerationFolders: ['NonExistent'], }, }); @@ -350,7 +396,7 @@ describe('parity — handleAutoGenerateUid vs handleAddMissingUidsInScope', () = settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], }, files: ['root.md', 'Notes/a.md', 'Notes/sub/b.md', 'Other/c.md'], }, @@ -368,7 +414,7 @@ describe('parity — handleAutoGenerateUid vs handleAddMissingUidsInScope', () = settings: { autoGenerateUid: true, autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], autoGenerationExclusions: ['Notes/private'], }, files: [ @@ -433,7 +479,7 @@ describe('handleAddMissingUidsInScope — bulk decision over many files', () => ], settings: { autoGenerationScope: 'folder', - autoGenerationFolder: 'Notes', + autoGenerationFolders: ['Notes'], autoGenerationExclusions: ['Notes/private'], }, }); diff --git a/src/commands.ts b/src/commands.ts index afe0518..e3568ab 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -317,9 +317,16 @@ export async function handleAutoGenerateUid(plugin: UIDGenerator, file: TFile | // Check scope if (plugin.settings.autoGenerationScope === 'folder') { - const normScope = normalizePath(plugin.settings.autoGenerationFolder.trim()); - if (!normScope || !(normalizedPath.startsWith(normScope + '/') || file.parent?.path === normScope)) { - return; // Outside scope + const folders = plugin.settings.autoGenerationFolders; + if (folders.length === 0) { + return; // No folders configured + } + const isInAnyFolder = folders.some(f => { + const normScope = normalizePath(f.trim()); + return normScope && (normalizedPath.startsWith(normScope + '/') || file.parent?.path === normScope); + }); + if (!isInAnyFolder) { + return; // Outside all scoped folders } } @@ -373,9 +380,17 @@ export async function handleAddMissingUidsInScope(plugin: UIDGenerator): Promise // 2. Check scope if not already excluded if (isInScope && plugin.settings.autoGenerationScope === 'folder') { - const normScope = normalizePath(plugin.settings.autoGenerationFolder.trim()); - if (!normScope || !(normalizedPath.startsWith(normScope + '/') || file.parent?.path === normScope)) { - isInScope = false; // Outside scope folder + const folders = plugin.settings.autoGenerationFolders; + if (folders.length === 0) { + isInScope = false; + } else { + const isInAnyFolder = folders.some(f => { + const normScope = normalizePath(f.trim()); + return normScope && (normalizedPath.startsWith(normScope + '/') || file.parent?.path === normScope); + }); + if (!isInAnyFolder) { + isInScope = false; + } } } diff --git a/src/main.ts b/src/main.ts index 10589a7..e163742 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { Editor, MarkdownView, Plugin, TFile, TFolder, debounce, Menu, TAbstractFile, WorkspaceLeaf, - FileExplorerView + FileExplorerView, normalizePath } from 'obsidian'; import { UIDGeneratorSettings, DEFAULT_SETTINGS, UIDSettingTab } from './settings'; import * as commands from './commands'; @@ -15,6 +15,17 @@ export default class UIDGenerator extends Plugin { async onload() { await this.loadSettings(); + // Refresh the cached machine Node ID for Snowflake on plugin load. + // The override (if any) takes precedence at generation time, but we + // still want the machine value to reflect the current hardware. + if (this.settings.uidGenerator === 'snowflake') { + const next = uidUtils.resolveAutoDetectedNodeId(this.settings.snowflakeNodeId); + if (next !== null) { + this.settings.snowflakeNodeId = next; + await this.saveSettings(); + } + } + // --- Ribbon Icon --- this.addRibbonIcon('fingerprint', `Create ${this.settings.uidKey} if missing`, () => { commands.handleCreateUidIfMissing(this); @@ -186,7 +197,7 @@ export default class UIDGenerator extends Plugin { } else if (fileOrFolder instanceof TFile && fileOrFolder.extension === 'md') { menu.addItem((item) => { item - .setTitle(`Copy title + ${this.settings.uidKey}`) // Use specific handler for single file + .setTitle(`Copy title + ${this.settings.uidKey}`) .setIcon('copy') .onClick(() => commands.handleCopyTitleAndUidForFile(this, fileOrFolder)); }); @@ -204,7 +215,6 @@ export default class UIDGenerator extends Plugin { item .setTitle(`Copy titles + ${this.settings.uidKey}s for ${markdownFiles.length} selected`) .setIcon('copy') - // Pass the filtered array of TFiles to the handler .onClick(() => commands.handleCopyTitlesAndUidsForMultipleFiles(this, markdownFiles)); }); } @@ -227,8 +237,39 @@ export default class UIDGenerator extends Plugin { if (!Array.isArray(this.settings.nanoidSeparators)) { this.settings.nanoidSeparators = []; } + if (!Array.isArray(this.settings.autoGenerationFolders)) { + this.settings.autoGenerationFolders = []; + } this.settings.copyFormatString = this.settings.copyFormatString || DEFAULT_SETTINGS.copyFormatString; this.settings.copyFormatStringMissingUid = this.settings.copyFormatStringMissingUid || DEFAULT_SETTINGS.copyFormatStringMissingUid; + + // One-shot migration from the legacy single-folder string to the array. + // Clears the old field so subsequent loads no-op without rewriting settings. + if (this.settings.autoGenerationFolder && this.settings.autoGenerationFolder.trim() !== '') { + const oldFolder = normalizePath(this.settings.autoGenerationFolder.trim()); + if (oldFolder && !this.settings.autoGenerationFolders.includes(oldFolder)) { + this.settings.autoGenerationFolders.push(oldFolder); + } + this.settings.autoGenerationFolder = ''; + await this.saveSettings(); + } + + // One-shot migration from the legacy Snowflake auto-detect toggle to + // the override field. Earlier PR builds stored a manual Node ID in + // `snowflakeNodeId` with `snowflakeAutoDetectNodeId === false`; carry + // that intent forward as a custom override so the user's pick survives. + if (typeof this.settings.snowflakeAutoDetectNodeId === 'boolean') { + if ( + this.settings.snowflakeAutoDetectNodeId === false + && this.settings.snowflakeNodeIdOverride === null + && this.settings.snowflakeNodeId > 0 + ) { + this.settings.snowflakeNodeIdOverride = this.settings.snowflakeNodeId; + this.settings.snowflakeNodeId = 0; // re-detect on next load + } + delete this.settings.snowflakeAutoDetectNodeId; + await this.saveSettings(); + } } async saveSettings() { diff --git a/src/settings.ts b/src/settings.ts index 3a3f3f7..8abc049 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -3,17 +3,23 @@ import UIDGenerator from './main'; import { FolderSuggest } from './ui/FolderSuggest'; import { ConfirmationModal } from './ui/ConfirmationModal'; import { FolderExclusionModal } from './ui/FolderExclusionModal'; +import { FolderSelectionModal } from './ui/FolderSelectionModal'; +import { detectNodeId, resolveAutoDetectedNodeId } from './uidUtils'; // --- Settings Interface --- export interface UIDGeneratorSettings { uidKey: string; autoGenerateUid: boolean; - uidGenerator: 'uuid' | 'nanoid' | 'ulid'; + uidGenerator: 'uuid' | 'nanoid' | 'ulid' | 'snowflake'; nanoidLength: number; nanoidAlphabet: string; nanoidSeparators: Array<{ char: string; position: number }>; + snowflakeNodeId: number; // machine-detected (or random mobile fallback) + snowflakeNodeIdOverride: number | null; // when set, overrides the machine value + snowflakeAutoDetectNodeId?: boolean; // legacy — kept only for migration from earlier PR builds autoGenerationScope: 'vault' | 'folder'; - autoGenerationFolder: string; + autoGenerationFolder: string; // kept for migration from older versions + autoGenerationFolders: string[]; autoGenerationExclusions: string[]; folderToClear: string; copyFormatString: string; @@ -28,8 +34,11 @@ export const DEFAULT_SETTINGS: UIDGeneratorSettings = { nanoidLength: 21, nanoidAlphabet: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', nanoidSeparators: [], + snowflakeNodeId: 0, + snowflakeNodeIdOverride: null, autoGenerationScope: 'vault', autoGenerationFolder: '', + autoGenerationFolders: [], autoGenerationExclusions: [], folderToClear: '', copyFormatString: '{title} - {uidKey}: {uid}', @@ -77,13 +86,14 @@ export class UIDSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Generator algorithm') - .setDesc('Choose between UUID (standard v4), NanoID (customizable), or ULID (sortable, 26 chars).') + .setDesc('Choose between UUID (standard v4), NanoID (customizable), ULID (sortable, 26 chars), or Snowflake (64-bit, time-sortable, distributed).') .addDropdown(dropdown => dropdown .addOption('uuid', 'UUID') .addOption('nanoid', 'NanoID') .addOption('ulid', 'ULID') + .addOption('snowflake', 'Snowflake') .setValue(this.plugin.settings.uidGenerator) - .onChange(async (value: 'uuid' | 'nanoid' | 'ulid') => { + .onChange(async (value: 'uuid' | 'nanoid' | 'ulid' | 'snowflake') => { this.plugin.settings.uidGenerator = value; await this.plugin.saveSettings(); this.display(); @@ -174,9 +184,78 @@ export class UIDSettingTab extends PluginSettingTab { })); } + if (this.plugin.settings.uidGenerator === 'snowflake') { + new Setting(containerEl).setName('Snowflake settings').setHeading(); + + // Refresh the cached machine value before rendering, so the field + // below shows the real detected value (not the lingering 0 from + // before the user picked Snowflake from the dropdown). + const next = resolveAutoDetectedNodeId(this.plugin.settings.snowflakeNodeId); + if (next !== null) { + this.plugin.settings.snowflakeNodeId = next; + // Fire-and-forget: display() is sync; the in-memory mutation + // takes effect immediately, the next save flushes to disk. + this.plugin.saveSettings(); + } + + const machineId = this.plugin.settings.snowflakeNodeId; + const override = this.plugin.settings.snowflakeNodeIdOverride; + const overrideActive = override !== null; + const detectedNow = detectNodeId(); + + const machineDesc = (detectedNow !== null + ? 'Auto-detected from this machine\'s MAC address.' + : 'No MAC address available (mobile or restricted environment); using a random persistent value.') + + (overrideActive + ? ' A Custom Node ID is set below — the machine value is currently NOT used.' + : ' This is the value used for generated UIDs.'); + + new Setting(containerEl) + .setName('Machine Node ID') + .setDesc(machineDesc) + .addText(text => { + text.setValue(String(machineId)); + text.setDisabled(true); + }); + + const overrideDesc = overrideActive + ? `Override active — generated UIDs use ${override} instead of the Machine Node ID above. Clear this field to fall back to the machine value.` + : 'Optional. If set, overrides the Machine Node ID for generated UIDs. Leave empty to use the machine value. Range: 0–1023.'; + + new Setting(containerEl) + .setName('Custom Node ID') + .setDesc(overrideDesc) + .addText(text => { + text.setPlaceholder('(none)'); + text.setValue(override === null ? '' : String(override)); + text.onChange(async (value) => { + const trimmed = value.trim(); + if (trimmed === '') { + if (this.plugin.settings.snowflakeNodeIdOverride !== null) { + this.plugin.settings.snowflakeNodeIdOverride = null; + await this.plugin.saveSettings(); + this.display(); + } + return; + } + const num = parseInt(trimmed, 10); + if (!isNaN(num) && num >= 0 && num <= 1023) { + if (this.plugin.settings.snowflakeNodeIdOverride !== num) { + this.plugin.settings.snowflakeNodeIdOverride = num; + await this.plugin.saveSettings(); + this.display(); + } + } + }); + }); + + } + // --- Automatic UID Generation --- - new Setting(containerEl).setName('Automatic uid generation').setHeading(); - containerEl.createEl('p', { text: `Automatically add a ${this.plugin.settings.uidKey} to notes when they are created or opened, if they don't already have one.` }).addClass('setting-item-description'); + new Setting(containerEl) + .setName('Automatic uid generation') + .setDesc(`Automatically add a ${this.plugin.settings.uidKey} to notes when they are created or opened, if they don't already have one.`) + .setHeading(); new Setting(containerEl) .setName('Enable automatic uid generation') @@ -194,7 +273,7 @@ export class UIDSettingTab extends PluginSettingTab { .setName('Generation scope') .addDropdown(dropdown => dropdown .addOption('vault', 'Entire Vault') - .addOption('folder', 'Specific Folder') + .addOption('folder', 'Specific Folder(s)') .setValue(this.plugin.settings.autoGenerationScope) .onChange(async (value: 'vault' | 'folder') => { this.plugin.settings.autoGenerationScope = value; @@ -202,20 +281,26 @@ export class UIDSettingTab extends PluginSettingTab { this.display(); })); - // Display folder input only if scope is 'folder' + // Display folder management only if scope is 'folder' if (this.plugin.settings.autoGenerationScope === 'folder') { new Setting(containerEl) - .setName('Target folder for auto-generation') - .setDesc('Generate uids only for notes in this folder (and subfolders).') - .addText(text => { - new FolderSuggest(this.app, text.inputEl); - text.setPlaceholder('Example: Notes/Inbox') - .setValue(this.plugin.settings.autoGenerationFolder) - .onChange(async (value) => { - this.plugin.settings.autoGenerationFolder = normalizePath(value.trim()); - await this.plugin.saveSettings(); - }); + .setName('Target folders for auto-generation') + .setDesc('Generate uids only for notes in these folders (and subfolders).') + .addButton(button => button + .setButtonText('Manage folders') + .onClick(() => { + new FolderSelectionModal(this.app, this.plugin, () => this.display()).open(); + })); + + const folderListEl = containerEl.createEl('ul', { cls: 'uid-exclusion-list' }); + if (this.plugin.settings.autoGenerationFolders.length > 0) { + const sortedFolders = [...this.plugin.settings.autoGenerationFolders].sort(); + sortedFolders.forEach(folderPath => { + folderListEl.createEl('li', { text: folderPath }); }); + } else { + folderListEl.createEl('li', { text: 'No target folders specified. Auto-generation is effectively disabled for folder scope.' }); + } } // --- Excluded Folders Setting with Modal Button --- diff --git a/src/ui/FolderSelectionModal.ts b/src/ui/FolderSelectionModal.ts new file mode 100644 index 0000000..8dfd705 --- /dev/null +++ b/src/ui/FolderSelectionModal.ts @@ -0,0 +1,97 @@ +import { App, Modal, TFolder, debounce } from 'obsidian'; +import UIDGenerator from '../main'; + +export class FolderSelectionModal extends Modal { + plugin: UIDGenerator; + allFolders: TFolder[]; + suggestionsEl: HTMLElement; + inputEl: HTMLInputElement; + onSettingsChanged: () => void; + + constructor(app: App, plugin: UIDGenerator, onSettingsChanged: () => void) { + super(app); + this.plugin = plugin; + this.allFolders = this.getAllFolders(); + this.onSettingsChanged = onSettingsChanged; + } + + getAllFolders(): TFolder[] { + return this.app.vault.getAllLoadedFiles() + .filter((f): f is TFolder => f instanceof TFolder) + .sort((a, b) => a.path.localeCompare(b.path)); + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('uid-folder-exclusion-modal'); + + contentEl.createEl('h2', { text: 'Manage target folders' }); + contentEl.createEl('p', { text: 'Add or remove folders for automatic uid generation scope.' }); + + // Search Input + this.inputEl = contentEl.createEl('input', { type: 'text', placeholder: 'Search folders...' }); + this.inputEl.addClass('uid-search-input'); + this.inputEl.addEventListener('input', debounce(() => this.renderSuggestions(this.inputEl.value), 150, true)); + + // Results Container + this.suggestionsEl = contentEl.createDiv('uid-suggestion-container'); + + this.renderSuggestions(''); + } + + renderSuggestions(searchTerm: string) { + this.suggestionsEl.empty(); + const lowerSearch = searchTerm.toLowerCase().trim(); + + const filteredFolders = lowerSearch === '' + ? this.allFolders + : this.allFolders.filter(folder => folder.path.toLowerCase().includes(lowerSearch)); + + if (filteredFolders.length === 0) { + this.suggestionsEl.createDiv({ text: 'No matching folders found.', cls: 'uid-no-results' }); + return; + } + + filteredFolders.forEach(folder => { + const isSelected = this.plugin.settings.autoGenerationFolders.includes(folder.path); + const settingItem = this.suggestionsEl.createDiv('setting-item'); + const infoDiv = settingItem.createDiv('setting-item-info'); + infoDiv.createDiv({ text: folder.path, cls: 'setting-item-name' }); + const controlDiv = settingItem.createDiv('setting-item-control'); + const button = controlDiv.createEl('button'); + + if (isSelected) { + button.setText('Remove'); + button.addClass('mod-warning'); + button.onclick = () => this.removeFolder(folder); + } else { + button.setText('Add'); + button.addClass('mod-cta'); + button.onclick = () => this.addFolder(folder); + } + }); + } + + async addFolder(folder: TFolder) { + if (!this.plugin.settings.autoGenerationFolders.includes(folder.path)) { + this.plugin.settings.autoGenerationFolders.push(folder.path); + this.plugin.settings.autoGenerationFolders.sort(); + await this.plugin.saveSettings(); + this.renderSuggestions(this.inputEl.value); + this.onSettingsChanged(); + } + } + + async removeFolder(folder: TFolder) { + this.plugin.settings.autoGenerationFolders = this.plugin.settings.autoGenerationFolders.filter(p => p !== folder.path); + await this.plugin.saveSettings(); + this.renderSuggestions(this.inputEl.value); + this.onSettingsChanged(); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/uidUtils.test.ts b/src/uidUtils.test.ts index 32dd32e..bb7461b 100644 --- a/src/uidUtils.test.ts +++ b/src/uidUtils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { TFile } from 'obsidian'; import { @@ -7,15 +7,20 @@ import { getUIDFromFile, setUID, removeUID, + _resetSnowflakeState, + detectNodeId, + resolveAutoDetectedNodeId, } from './uidUtils'; import type UIDGenerator from './main'; type Settings = { uidKey: string; - uidGenerator: 'uuid' | 'nanoid' | 'ulid'; + uidGenerator: 'uuid' | 'nanoid' | 'ulid' | 'snowflake'; nanoidLength: number; nanoidAlphabet: string; nanoidSeparators: Array<{ char: string; position: number }>; + snowflakeNodeId?: number; + snowflakeNodeIdOverride?: number | null; }; function makePlugin( @@ -595,3 +600,193 @@ describe('removeUID', () => { expect(uidPathMap.has('note.md')).toBe(false); }); }); + +describe('Snowflake ID generator', () => { + beforeEach(() => { + _resetSnowflakeState(); + }); + + function snowflakePlugin(nodeId: number): UIDGenerator { + return makePlugin({ uidGenerator: 'snowflake', snowflakeNodeId: nodeId }); + } + + // Layout (lsb → msb): 12 bits sequence, 10 bits node, 41 bits timestamp. + function decode(id: string): { timestamp: bigint; nodeId: bigint; sequence: bigint } { + const big = BigInt(id); + return { + sequence: big & 0xfffn, + nodeId: (big >> 12n) & 0x3ffn, + timestamp: big >> 22n, + }; + } + + it('produces a numeric string', () => { + const id = generateUID(snowflakePlugin(7)); + expect(id).toMatch(/^\d+$/); + }); + + it('encodes the configured node ID into the middle bits', () => { + const id = generateUID(snowflakePlugin(42)); + expect(decode(id).nodeId).toBe(42n); + }); + + it('uses the override Node ID when set, ignoring the machine value', () => { + const plugin = makePlugin({ + uidGenerator: 'snowflake', + snowflakeNodeId: 5, + snowflakeNodeIdOverride: 99, + }); + expect(decode(generateUID(plugin)).nodeId).toBe(99n); + }); + + it('falls back to the machine Node ID when the override is null', () => { + const plugin = makePlugin({ + uidGenerator: 'snowflake', + snowflakeNodeId: 5, + snowflakeNodeIdOverride: null, + }); + expect(decode(generateUID(plugin)).nodeId).toBe(5n); + }); + + it('clamps node IDs above the 10-bit max (1023) into range', () => { + // nodeId 2048 has bit 11 set; after & 1023 it should land at 0. + const id = generateUID(snowflakePlugin(2048)); + expect(decode(id).nodeId).toBe(0n); + }); + + it('increments the sequence within the same millisecond', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + try { + const a = decode(generateUID(snowflakePlugin(1))); + const b = decode(generateUID(snowflakePlugin(1))); + const c = decode(generateUID(snowflakePlugin(1))); + expect(a.timestamp).toBe(b.timestamp); + expect(b.timestamp).toBe(c.timestamp); + expect(b.sequence).toBe(a.sequence + 1n); + expect(c.sequence).toBe(b.sequence + 1n); + } finally { + vi.restoreAllMocks(); + } + }); + + it('resets the sequence when the timestamp advances', () => { + const nowSpy = vi.spyOn(Date, 'now'); + nowSpy.mockReturnValue(1_700_000_000_000); + try { + generateUID(snowflakePlugin(1)); + generateUID(snowflakePlugin(1)); + nowSpy.mockReturnValue(1_700_000_000_001); + const next = decode(generateUID(snowflakePlugin(1))); + expect(next.sequence).toBe(0n); + expect(next.timestamp).toBe(BigInt(1_700_000_000_001) & ((1n << 41n) - 1n)); + } finally { + vi.restoreAllMocks(); + } + }); + + it('survives a backwards clock jump without deadlocking', () => { + const nowSpy = vi.spyOn(Date, 'now'); + nowSpy.mockReturnValue(1_700_000_000_010); + try { + const before = decode(generateUID(snowflakePlugin(3))); + // Clock jumps backwards (NTP / suspend-resume). + nowSpy.mockReturnValue(1_700_000_000_000); + const after = decode(generateUID(snowflakePlugin(3))); + // Generator must keep moving forward, not spin or regress. + expect(after.timestamp).toBeGreaterThan(before.timestamp); + } finally { + vi.restoreAllMocks(); + } + }); + + it('produces strictly increasing IDs across many calls in one ms', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + try { + const ids = Array.from({ length: 200 }, () => BigInt(generateUID(snowflakePlugin(1)))); + for (let i = 1; i < ids.length; i++) { + expect(ids[i] > ids[i - 1]).toBe(true); + } + } finally { + vi.restoreAllMocks(); + } + }); +}); + +describe('Snowflake Node ID auto-detection', () => { + describe('resolveAutoDetectedNodeId', () => { + it('returns the detected value when it differs from stored', () => { + expect(resolveAutoDetectedNodeId(0, () => 799)).toBe(799); + expect(resolveAutoDetectedNodeId(42, () => 799)).toBe(799); + }); + + it('returns null when detected matches stored (no churn)', () => { + expect(resolveAutoDetectedNodeId(799, () => 799)).toBeNull(); + }); + + it('mobile path: picks a random 10-bit value when stored is 0 and no MAC available', () => { + const randSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5); + try { + const result = resolveAutoDetectedNodeId(0, () => null); + expect(result).toBe(512); + } finally { + randSpy.mockRestore(); + } + }); + + it('mobile path: keeps an already-picked random value (stored > 0, no MAC)', () => { + expect(resolveAutoDetectedNodeId(312, () => null)).toBeNull(); + }); + + it('produces only values in 1..1023 on the mobile path (never 0, never >1023)', () => { + // 0 is the "not yet picked" sentinel — picking it would cause re-rolls + // on every load, breaking node-ID stability across sessions. + const cases = [0.0, 0.0001, 0.5, 0.99, 0.999999]; + for (const r of cases) { + const spy = vi.spyOn(Math, 'random').mockReturnValue(r); + try { + const result = resolveAutoDetectedNodeId(0, () => null); + if (result === null) throw new Error('expected non-null on mobile path with stored=0'); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(1023); + } finally { + spy.mockRestore(); + } + } + }); + + it('persists the mobile-fallback value across consecutive resolves', () => { + // Simulate two plugin loads on a mobile device. The first picks a + // random value; the second must see "already picked" and not re-roll. + vi.spyOn(Math, 'random').mockReturnValue(0.4); + try { + const first = resolveAutoDetectedNodeId(0, () => null); + if (first === null) throw new Error('expected first load to pick a value'); + // Subsequent load sees the value the first load saved. + const second = resolveAutoDetectedNodeId(first, () => null); + expect(second).toBeNull(); + } finally { + vi.restoreAllMocks(); + } + }); + }); + + describe('detectNodeId', () => { + // Cross-platform smoke test: this runs on the developer's machine, + // which has a real `os` module. The function must either return a + // valid 10-bit number, or null — never throw, never return out of range. + it('never throws and always returns null or a value in 0–1023', () => { + let result: number | null = null; + expect(() => { result = detectNodeId(); }).not.toThrow(); + if (result !== null) { + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(1023); + } + }); + + it('returns a deterministic value on repeated calls (same machine → same node ID)', () => { + const a = detectNodeId(); + const b = detectNodeId(); + expect(a).toBe(b); + }); + }); +}); diff --git a/src/uidUtils.ts b/src/uidUtils.ts index 20f10f4..8f2433b 100644 --- a/src/uidUtils.ts +++ b/src/uidUtils.ts @@ -19,8 +19,124 @@ function getNanoidGenerator(alphabet: string, length: number): () => string { return cachedNanoid; } +// --- Snowflake ID Generator --- +const SNOWFLAKE_NODE_ID_BITS = 10n; +const SNOWFLAKE_SEQUENCE_BITS = 12n; +const SNOWFLAKE_MAX_SEQUENCE = (1n << SNOWFLAKE_SEQUENCE_BITS) - 1n; // 4095 +const SNOWFLAKE_MAX_NODE_ID = (1n << SNOWFLAKE_NODE_ID_BITS) - 1n; // 1023 +const SNOWFLAKE_NODE_ID_SHIFT = SNOWFLAKE_SEQUENCE_BITS; // 12 +const SNOWFLAKE_TIMESTAMP_SHIFT = SNOWFLAKE_NODE_ID_BITS + SNOWFLAKE_SEQUENCE_BITS; // 22 + +let snowflakeLastTimestamp = -1n; +let snowflakeSequence = 0n; + +// Bounds the spin-wait when the per-ms sequence overflows. 4096 ids/ms is +// the absolute throughput; anything below that wraps within the same ms. +const SNOWFLAKE_SPIN_WAIT_MAX_MS = 5n; + +function generateSnowflakeID(nodeId: number): string { + const nodeIdBigInt = BigInt(nodeId) & SNOWFLAKE_MAX_NODE_ID; + + let timestamp = BigInt(Date.now()); + + // Clock moved backwards (NTP, suspend/resume): bump the stored timestamp + // forward by one ms instead of spinning. IDs stay monotonic across the + // jump at the cost of a small future-skew until wall time catches up. + if (timestamp < snowflakeLastTimestamp) { + timestamp = snowflakeLastTimestamp + 1n; + snowflakeSequence = 0n; + } else if (timestamp === snowflakeLastTimestamp) { + snowflakeSequence = (snowflakeSequence + 1n) & SNOWFLAKE_MAX_SEQUENCE; + if (snowflakeSequence === 0n) { + // Sequence exhausted for this ms — wait for the next ms, but bound + // the spin so a stuck clock can't deadlock the plugin. + const spinDeadline = timestamp + SNOWFLAKE_SPIN_WAIT_MAX_MS; + while (timestamp <= snowflakeLastTimestamp && timestamp < spinDeadline) { + timestamp = BigInt(Date.now()); + } + if (timestamp <= snowflakeLastTimestamp) { + timestamp = snowflakeLastTimestamp + 1n; + } + } + } else { + snowflakeSequence = 0n; + } + + snowflakeLastTimestamp = timestamp; + + const id = (timestamp << SNOWFLAKE_TIMESTAMP_SHIFT) + | (nodeIdBigInt << SNOWFLAKE_NODE_ID_SHIFT) + | snowflakeSequence; + + return id.toString(); +} + +/** Test-only: reset Snowflake module state between cases. */ +export function _resetSnowflakeState(): void { + snowflakeLastTimestamp = -1n; + snowflakeSequence = 0n; +} + /** - * Generates a unique ID using UUID v4 or NanoID based on settings. + * Resolves the Node ID auto-detect should produce for the given stored value. + * Returns the new value to persist, or null if the stored value is already + * correct (or no detection / fallback applies). + * + * - Desktop: returns the MAC-derived ID when it differs from `stored`. + * - Mobile (no MAC): returns a random value in 1–1023 when `stored === 0`, + * so the random fallback is picked once and then preserved across reloads. + * + * The random range deliberately excludes 0: `stored === 0` is the sentinel + * for "not yet picked". If the random pick could land on 0, it would be + * indistinguishable from the unset state and re-roll on every plugin load, + * breaking ID stability across sessions. + * + * The `detect` parameter is for unit testing. Production callers omit it + * and get the real MAC-based detector. + */ +export function resolveAutoDetectedNodeId( + stored: number, + detect: () => number | null = detectNodeId, +): number | null { + const detected = detect(); + if (detected !== null) { + return detected !== stored ? detected : null; + } + if (stored === 0) { + return Math.floor(Math.random() * 1023) + 1; + } + return null; +} + +/** + * Attempts to derive a stable 10-bit node ID (0-1023) from the machine's MAC address. + * Available on Electron (desktop). Returns null on mobile or if detection fails. + */ +export function detectNodeId(): number | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const os = require('os'); + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') { + const mac = iface.mac.replace(/:/g, ''); + let hash = 0; + for (let i = 0; i < mac.length; i++) { + hash = ((hash << 5) - hash + mac.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 1024; + } + } + } + } catch { + // os module not available (mobile) + } + return null; +} + +/** + * Generates a unique ID using UUID v4, NanoID, ULID, or Snowflake based on settings. * @param plugin The UIDGenerator plugin instance (for settings). */ const MAX_COLLISION_RETRIES = 10; @@ -35,22 +151,34 @@ export function generateUID(plugin: UIDGenerator): string { } // All retries exhausted — notify the user and return the duplicate as last resort const fallback = generateRawUID(plugin); - const { nanoidAlphabet, nanoidLength } = plugin.settings; - const totalCombinations = Math.pow(nanoidAlphabet.length, nanoidLength); - const combinationsStr = totalCombinations > 1e15 - ? totalCombinations.toExponential(2) - : totalCombinations.toLocaleString(); console.error(`[UIDGenerator] Failed to generate unique ID after ${MAX_COLLISION_RETRIES} attempts.`); - new Notice( - `Warning: Could not generate a unique ${plugin.settings.uidKey} after ${MAX_COLLISION_RETRIES} attempts. ` + - `Current settings allow ~${combinationsStr} combinations (alphabet: ${nanoidAlphabet.length} chars, length: ${nanoidLength}). ` + - `Consider increasing NanoID length or alphabet size.`, - 15000 - ); + + if (plugin.settings.uidGenerator === 'nanoid') { + const { nanoidAlphabet, nanoidLength } = plugin.settings; + const totalCombinations = Math.pow(nanoidAlphabet.length, nanoidLength); + const combinationsStr = totalCombinations > 1e15 + ? totalCombinations.toExponential(2) + : totalCombinations.toLocaleString(); + new Notice( + `Warning: Could not generate a unique ${plugin.settings.uidKey} after ${MAX_COLLISION_RETRIES} attempts. ` + + `Current settings allow ~${combinationsStr} combinations (alphabet: ${nanoidAlphabet.length} chars, length: ${nanoidLength}). ` + + `Consider increasing NanoID length or alphabet size.`, + 15000 + ); + } else { + new Notice( + `Warning: Could not generate a unique ${plugin.settings.uidKey} after ${MAX_COLLISION_RETRIES} attempts.`, + 15000 + ); + } return fallback; } function generateRawUID(plugin: UIDGenerator): string { + if (plugin.settings.uidGenerator === 'snowflake') { + const effective = plugin.settings.snowflakeNodeIdOverride ?? plugin.settings.snowflakeNodeId; + return generateSnowflakeID(effective); + } if (plugin.settings.uidGenerator === 'nanoid') { const { nanoidAlphabet, nanoidLength, nanoidSeparators } = plugin.settings; const nanoid = getNanoidGenerator(nanoidAlphabet, nanoidLength); diff --git a/tests/fakes/app.ts b/tests/fakes/app.ts index c77c484..0efa11a 100644 --- a/tests/fakes/app.ts +++ b/tests/fakes/app.ts @@ -16,8 +16,11 @@ const DEFAULT_SETTINGS: UIDGeneratorSettings = { nanoidLength: 21, nanoidAlphabet: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', nanoidSeparators: [], + snowflakeNodeId: 0, + snowflakeNodeIdOverride: null, autoGenerationScope: 'vault', autoGenerationFolder: '', + autoGenerationFolders: [], autoGenerationExclusions: [], folderToClear: '', copyFormatString: '{title} - {uidKey}: {uid}', diff --git a/tsconfig.json b/tsconfig.json index 76b4dbf..bf791e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", - "target": "ES6", + "target": "ES2020", "allowJs": true, "noImplicitAny": true, "moduleResolution": "node", @@ -15,7 +15,8 @@ "DOM", "ES5", "ES6", - "ES7" + "ES7", + "ES2020" ] }, "typeRoots": [