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": [