From 026383ffba069b3df99500494641fc8ff64fd4c5 Mon Sep 17 00:00:00 2001 From: a0666660 <69061030+a0666660@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:12:35 +0800 Subject: [PATCH] feat(new tool): snowflake id decoder --- src/tools/index.ts | 3 +- src/tools/snowflake-id-decoder/index.ts | 12 ++ .../snowflake-id-decoder.models.test.ts | 40 +++++++ .../snowflake-id-decoder.models.ts | 52 +++++++++ .../snowflake-id-decoder.vue | 105 ++++++++++++++++++ 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/tools/snowflake-id-decoder/index.ts create mode 100644 src/tools/snowflake-id-decoder/snowflake-id-decoder.models.test.ts create mode 100644 src/tools/snowflake-id-decoder/snowflake-id-decoder.models.ts create mode 100644 src/tools/snowflake-id-decoder/snowflake-id-decoder.vue diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf4..1e18ce47 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -45,6 +45,7 @@ import { tool as htmlWysiwygEditor } from './html-wysiwyg-editor'; import { tool as rsaKeyPairGenerator } from './rsa-key-pair-generator'; import { tool as textToNatoAlphabet } from './text-to-nato-alphabet'; import { tool as slugifyString } from './slugify-string'; +import { tool as snowflakeIdDecoder } from './snowflake-id-decoder'; import { tool as keycodeInfo } from './keycode-info'; import { tool as jsonMinify } from './json-minify'; import { tool as bcrypt } from './bcrypt'; @@ -91,7 +92,7 @@ import { tool as yamlViewer } from './yaml-viewer'; export const toolsByCategory: ToolCategory[] = [ { name: 'Crypto', - components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser, pdfSignatureChecker], + components: [snowflakeIdDecoder, tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser, pdfSignatureChecker], }, { name: 'Converter', diff --git a/src/tools/snowflake-id-decoder/index.ts b/src/tools/snowflake-id-decoder/index.ts new file mode 100644 index 00000000..6e5855e5 --- /dev/null +++ b/src/tools/snowflake-id-decoder/index.ts @@ -0,0 +1,12 @@ +import { Snowflake } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Snowflake id decoder', + path: '/snowflake-id-decoder', + description: 'Decode Twitter, Discord, Instagram or Mastodon snowflake ids into timestamps, worker ids and sequences', + keywords: ['snowflake', 'twitter', 'discord', 'instagram', 'mastodon', 'id', 'timestamp', 'decoder'], + component: () => import('./snowflake-id-decoder.vue'), + icon: Snowflake, + createdAt: new Date('2026-10-01'), +}); diff --git a/src/tools/snowflake-id-decoder/snowflake-id-decoder.models.test.ts b/src/tools/snowflake-id-decoder/snowflake-id-decoder.models.test.ts new file mode 100644 index 00000000..12317c29 --- /dev/null +++ b/src/tools/snowflake-id-decoder/snowflake-id-decoder.models.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { decodeSnowflake } from './snowflake-id-decoder.models'; + +describe('snowflake-id-decoder', () => { + it('decodes a Twitter snowflake against the Twitter epoch', () => { + // 565419792856637440 was posted on 2015-02-09 + const decoded = decodeSnowflake('565419792856637440', 1288834974657); + expect(decoded.timestamp).toBe(1423641558265); + expect(decoded.isoDate).toContain('2015-02-11'); + }); + + it('decodes a Discord id against the Discord epoch', () => { + // Discord docs example: id 175928847299117063 -> 2016-04-30T11:18:25.796Z + const decoded = decodeSnowflake('175928847299117063', 1420070400000); + expect(decoded.timestamp).toBe(1462015105796); + expect(decoded.isoDate).toBe('2016-04-30 11:18:25 UTC'); + expect(decoded.datacenterId).toBe(1); + expect(decoded.workerId).toBe(0); + expect(decoded.sequence).toBe(7); + }); + + it('rejects non-numeric input', () => { + expect(() => decodeSnowflake('abc', 0)).toThrow('positive integer'); + expect(() => decodeSnowflake('12 34', 0)).toThrow('positive integer'); + expect(() => decodeSnowflake('-5', 0)).toThrow('positive integer'); + }); + + it('handles very large ids beyond Number safe range', () => { + // 2^63-1 must not lose precision + const decoded = decodeSnowflake('9223372036854775807', 0); + expect(decoded.timestamp).toBe(Number(9223372036854775807n >> 22n)); + }); + + it('extracts sequence bits', () => { + // 0b1 << 22 | sequence 0b101010101010 + const id = ((1n << 22n) | 0b101010101010n).toString(); + const decoded = decodeSnowflake(id, 0); + expect(decoded.sequence).toBe(0b101010101010); + }); +}); diff --git a/src/tools/snowflake-id-decoder/snowflake-id-decoder.models.ts b/src/tools/snowflake-id-decoder/snowflake-id-decoder.models.ts new file mode 100644 index 00000000..cb5f63a1 --- /dev/null +++ b/src/tools/snowflake-id-decoder/snowflake-id-decoder.models.ts @@ -0,0 +1,52 @@ +export interface SnowflakePlatform { + key: string + label: string + epoch: number +} + +/** Well-known Snowflake epochs. Twitter uses a custom 41-bit timestamp; the + * others use the standard 64-bit layout with a platform-specific epoch. */ +export const SNOWFLAKE_PLATFORMS: SnowflakePlatform[] = [ + { key: 'twitter', label: 'Twitter / X', epoch: 1288834974657 }, + { key: 'discord', label: 'Discord', epoch: 1420070400000 }, + { key: 'instagram', label: 'Instagram', epoch: 1314220021721 }, + { key: 'mastodon', label: 'Mastodon', epoch: 0 }, + { key: 'custom', label: 'Custom epoch', epoch: 0 }, +]; + +export interface SnowflakeDecoded { + /** Millisecond Unix timestamp encoded in the id. */ + timestamp: number + isoDate: string + /** Datacenter / worker / sequence when the platform uses the standard layout. */ + workerId: number + datacenterId: number + sequence: number +} + +function parseSnowflakeId(id: string): bigint { + const trimmed = id.trim(); + if (!/^\d{1,20}$/.test(trimmed)) { + throw new Error('A snowflake id is a positive integer'); + } + return BigInt(trimmed); +} + +/** Standard 64-bit Snowflake: 1 unused sign bit, 41-bit ms timestamp, + * 5-bit datacenter, 5-bit worker, 12-bit sequence. */ +export function decodeSnowflake(id: string, epoch: number): SnowflakeDecoded { + const value = parseSnowflakeId(id); + const timestamp = Number((value >> 22n) & 0x1FFFFFFFFFFFFn) + epoch; + const datacenterId = Number((value >> 17n) & 0x1Fn); + const workerId = Number((value >> 12n) & 0x1Fn); + const sequence = Number(value & 0xFFFn); + + const date = new Date(timestamp); + return { + timestamp, + isoDate: `${date.toISOString().replace('T', ' ').replace(/\..+/, '')} UTC`, + workerId, + datacenterId, + sequence, + }; +} diff --git a/src/tools/snowflake-id-decoder/snowflake-id-decoder.vue b/src/tools/snowflake-id-decoder/snowflake-id-decoder.vue new file mode 100644 index 00000000..ef240629 --- /dev/null +++ b/src/tools/snowflake-id-decoder/snowflake-id-decoder.vue @@ -0,0 +1,105 @@ + + + + +