@ryuzaki13/react-foundation-lib 2.16.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,6 +89,7 @@ npm run pack:dry-run
89
89
  | Tree table преобразования | [`tree-table`](./src/tree-table/index.ts), документация в общем справочнике [`table/README.mdx`](./src/table/README.mdx), тесты в [`tree-table`](./src/tree-table) |
90
90
  | Excel export | [`excel`](./src/excel/index.ts), подробный README: [`excel/README.mdx`](./src/excel/README.mdx), тесты: [`excel.test.ts`](./src/excel/excel.test.ts), [`tableExport.test.ts`](./src/excel/tableExport.test.ts) |
91
91
  | FileReader, изображения, base64 Blob | [`file`](./src/file/index.ts), [`binary`](./src/binary/index.ts) |
92
+ | Проверка серверного пути внутри root, ошибки отсутствующего файла | [`file-system`](./src/file-system/index.ts), Node.js API: [`file-system/README.mdx`](./src/file-system/README.mdx) |
92
93
  | DOM overlay, portal, focus, download | [`dom`](./src/dom/index.ts), тест: [`dom.test.tsx`](./src/dom/dom.test.tsx) |
93
94
  | Debounce/throttle, DnD sensors, search params, listbox | [`hooks`](./src/hooks/index.ts), подробный README: [`hooks/README.mdx`](./src/hooks/README.mdx), [`utils/keyboard.ts`](./src/utils/keyboard.ts) |
94
95
  | Breakpoints, media queries и responsive props | [`media`](./src/media/index.ts), подробный README: [`media/README.mdx`](./src/media/README.mdx), тест: [`media.test.ts`](./src/media/media.test.ts) |
@@ -118,6 +119,7 @@ npm run pack:dry-run
118
119
  | `/error-report` | [`error-report/README.mdx`](./src/error-report/README.mdx) |
119
120
  | `/excel` | [`excel/README.mdx`](./src/excel/README.mdx) |
120
121
  | `/file` | [`file/README.mdx`](./src/file/README.mdx) |
122
+ | `/file-system` | [Node.js: пути внутри хранилища](./src/file-system/README.mdx) |
121
123
  | `/fingerprint` | [`fingerprint/README.mdx`](./src/fingerprint/README.mdx) |
122
124
  | `/form` | [`form/README.mdx`](./src/form/README.mdx) |
123
125
  | `/formatters` | [`formatters/README.mdx`](./src/formatters/README.mdx) и его предметные README |
@@ -528,10 +530,11 @@ Pipeline — runtime форматирования ячейки. Он прини
528
530
  | API | Поведение |
529
531
  | ---------------- | ------------------------------------------------------------------------------ |
530
532
  | `binaryToBlob` | Base64/data URL/raw binary string -> `Blob`; валидирует пустые и битые данные. |
533
+ | `getBase64DecodedSize` | Проверяет стандартный Base64 и считает размер без декодирования; неверная форма -> `null`, пустая строка -> `0`. |
531
534
  | `detectMimeType` | MIME по filename, data URL, byte signature, fallback `application/pdf`. |
532
535
  | `useBinaryFile` | Hook: base64 string -> `{ blob, mime }` или error; object URL не создает. |
533
536
 
534
- Тест: [`binaryToBlob.test.ts`](./src/binary/binaryToBlob.test.ts).
537
+ Тесты: [`binaryToBlob.test.ts`](./src/binary/binaryToBlob.test.ts), [`getBase64DecodedSize.test.ts`](./src/binary/getBase64DecodedSize.test.ts).
535
538
 
536
539
  ### `xml`
537
540
 
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Проверяет стандартную Base64-форму и считает байты без декодирования или создания Buffer/Blob.
3
+ * Невалидный payload возвращает null, пустая строка — 0; допустимый размер определяет вызывающий контракт.
4
+ * Data URL, whitespace, URL-safe алфавит и отсутствующий padding здесь не нормализуются.
5
+ */
6
+ export declare function getBase64DecodedSize(value: string): number | null;
7
+ //# sourceMappingURL=getBase64DecodedSize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"getBase64DecodedSize.d.ts","sourceRoot":"","sources":["../../src/binary/getBase64DecodedSize.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMjE"}
@@ -1,3 +1,4 @@
1
1
  export * from "./binaryToBlob";
2
+ export { getBase64DecodedSize } from "./getBase64DecodedSize";
2
3
  export * from "./useBinaryFile";
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/binary/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/binary/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,cAAc,iBAAiB,CAAC"}
@@ -99,10 +99,15 @@ var i = "application/octet-stream", a = "application/pdf", o = /^data:([^,]*);ba
99
99
  if (n) return n;
100
100
  }
101
101
  return a;
102
- };
102
+ }, y = /[^A-Za-z0-9+/=]/;
103
+ function b(e) {
104
+ if (e.length % 4 != 0 || y.test(e)) return null;
105
+ let t = e.endsWith("==") ? 2 : +!!e.endsWith("="), n = e.indexOf("=");
106
+ return n !== -1 && n !== e.length - t ? null : Math.floor(e.length * 3 / 4) - t;
107
+ }
103
108
  //#endregion
104
109
  //#region src/binary/useBinaryFile.ts
105
- function y(i) {
110
+ function x(i) {
106
111
  let [a, o] = r(null), [s, c] = r(null), l = n((e) => o(e)), u = n((e) => c(e));
107
112
  return t(() => {
108
113
  if (i) try {
@@ -121,6 +126,6 @@ function y(i) {
121
126
  };
122
127
  }
123
128
  //#endregion
124
- export { _ as binaryToBlob, v as detectMimeType, y as useBinaryFile };
129
+ export { _ as binaryToBlob, v as detectMimeType, b as getBase64DecodedSize, x as useBinaryFile };
125
130
 
126
131
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/binary/binaryToBlob.ts","../../src/binary/useBinaryFile.ts"],"sourcesContent":["const DEFAULT_BLOB_MIME_TYPE = \"application/octet-stream\";\nconst DEFAULT_DOCUMENT_MIME_TYPE = \"application/pdf\";\nconst BASE64_DATA_URL_PATTERN = /^data:([^,]*);base64,([\\s\\S]*)$/i;\n\nconst MIME_TYPE_BY_EXTENSION: Record<string, string> = {\n\tbmp: \"image/bmp\",\n\tcsv: \"text/csv\",\n\tdoc: \"application/msword\",\n\tdocx: \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\tgif: \"image/gif\",\n\tjpeg: \"image/jpeg\",\n\tjpg: \"image/jpeg\",\n\tjson: \"application/json\",\n\tpdf: \"application/pdf\",\n\tpng: \"image/png\",\n\tppt: \"application/vnd.ms-powerpoint\",\n\tpptx: \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\tsvg: \"image/svg+xml\",\n\ttif: \"image/tiff\",\n\ttiff: \"image/tiff\",\n\ttxt: \"text/plain\",\n\twebp: \"image/webp\",\n\txls: \"application/vnd.ms-excel\",\n\txlsx: \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\txml: \"application/xml\",\n\tzip: \"application/zip\"\n};\n\nconst normalizeBase64 = (value: string): string => {\n\tconst normalized = value.replace(/\\s/g, \"\").replace(/-/g, \"+\").replace(/_/g, \"/\");\n\tconst paddingRest = normalized.length % 4;\n\n\tif (paddingRest === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {\n\t\tthrow new Error(\"Некорректный формат base64\");\n\t}\n\n\treturn paddingRest === 0 ? normalized : `${normalized}${\"=\".repeat(4 - paddingRest)}`;\n};\n\nconst getBase64RawPayload = (binaryData: string): { data: string; mimeType: string | null } => {\n\tconst dataUrlMatch = binaryData.match(BASE64_DATA_URL_PATTERN);\n\n\tif (!dataUrlMatch) {\n\t\treturn { data: binaryData, mimeType: null };\n\t}\n\n\tconst [, rawMimeType, rawData] = dataUrlMatch;\n\tconst mimeType = rawMimeType.split(\";\")[0].trim();\n\n\treturn {\n\t\tdata: rawData,\n\t\tmimeType: mimeType || null\n\t};\n};\n\nconst getBase64Payload = (binaryData: string): { data: string; mimeType: string | null } => {\n\tconst payload = getBase64RawPayload(binaryData);\n\n\treturn {\n\t\tdata: normalizeBase64(payload.data),\n\t\tmimeType: payload.mimeType\n\t};\n};\n\nconst getDataUrlMimeType = (binaryData: string): string | null => {\n\tconst dataUrlMatch = binaryData.match(BASE64_DATA_URL_PATTERN);\n\n\tif (!dataUrlMatch) {\n\t\treturn null;\n\t}\n\n\tconst mimeType = dataUrlMatch[1].split(\";\")[0].trim();\n\n\treturn mimeType || null;\n};\n\nconst decodeBase64ToBytes = (binaryData: string): Uint8Array => {\n\tconst { data } = getBase64Payload(binaryData);\n\tconst binaryString = atob(data);\n\tconst byteArray = new Uint8Array(binaryString.length);\n\n\tfor (let index = 0; index < binaryString.length; index++) {\n\t\tbyteArray[index] = binaryString.charCodeAt(index);\n\t}\n\n\treturn byteArray;\n};\n\nconst binaryStringToBytes = (binaryData: string): Uint8Array => {\n\tconst byteArray = new Uint8Array(binaryData.length);\n\n\tfor (let index = 0; index < binaryData.length; index++) {\n\t\tbyteArray[index] = binaryData.charCodeAt(index);\n\t}\n\n\treturn byteArray;\n};\n\nconst copyBytesToArrayBuffer = (byteArray: Uint8Array): ArrayBuffer => {\n\tconst buffer = new ArrayBuffer(byteArray.byteLength);\n\tconst view = new Uint8Array(buffer);\n\n\tview.set(byteArray);\n\n\treturn buffer;\n};\n\nconst decodeHeaderFromBase64 = (binaryData: string): string | null => {\n\ttry {\n\t\tconst { data } = getBase64RawPayload(binaryData);\n\t\tconst headerData = data.slice(0, 128);\n\t\tconst binaryString = atob(normalizeBase64(headerData));\n\n\t\treturn binaryString;\n\t} catch {\n\t\treturn null;\n\t}\n};\n\nconst detectMimeTypeBySignature = (binaryData: string): string | null => {\n\tconst header = decodeHeaderFromBase64(binaryData);\n\n\tif (!header) {\n\t\treturn null;\n\t}\n\n\tconst bytes = new Uint8Array(header.length);\n\n\tfor (let index = 0; index < header.length; index++) {\n\t\tbytes[index] = header.charCodeAt(index);\n\t}\n\n\tif (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {\n\t\treturn \"application/pdf\";\n\t}\n\n\tif (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {\n\t\treturn \"image/jpeg\";\n\t}\n\n\tif (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {\n\t\treturn \"image/png\";\n\t}\n\n\tif (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {\n\t\treturn \"image/gif\";\n\t}\n\n\tif (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {\n\t\treturn \"image/webp\";\n\t}\n\n\tif (bytes[0] === 0x50 && bytes[1] === 0x4b) {\n\t\treturn \"application/zip\";\n\t}\n\n\tconst textHeader = header\n\t\t.replace(/^\\uFEFF/, \"\")\n\t\t.trimStart()\n\t\t.slice(0, 16);\n\n\tif (textHeader.startsWith(\"<?xml\") || textHeader.startsWith(\"<\")) {\n\t\treturn \"application/xml\";\n\t}\n\n\treturn null;\n};\n\n/**\n * Преобразует base64 или бинарную строку в Blob.\n *\n * Data URL с префиксом `data:*;base64,` поддерживается, сам URL здесь не создаётся.\n */\nexport const binaryToBlob = (binaryData: string, mimeType: string = DEFAULT_BLOB_MIME_TYPE, isBase64 = true): Blob => {\n\tif (!binaryData) {\n\t\tthrow new Error(\"Не переданы бинарные данные\");\n\t}\n\n\ttry {\n\t\tconst byteArray = isBase64 ? decodeBase64ToBytes(binaryData) : binaryStringToBytes(binaryData);\n\n\t\tif (byteArray.length === 0) {\n\t\t\tthrow new Error(\"После преобразования не осталось данных\");\n\t\t}\n\n\t\treturn new Blob([copyBytesToArrayBuffer(byteArray)], { type: mimeType.trim() || DEFAULT_BLOB_MIME_TYPE });\n\t} catch (error) {\n\t\tif (error instanceof Error) {\n\t\t\tthrow error;\n\t\t}\n\t\tthrow new Error(\"Некорректный формат бинарных данных\");\n\t}\n};\n\n/**\n * Определяет MIME-тип по имени файла, data URL или сигнатуре base64-данных.\n *\n * Если определить тип не удалось, используется PDF: большинство документов в этом контуре приходят именно так.\n */\nexport const detectMimeType = (fileName?: string, binaryData?: string): string => {\n\tif (fileName) {\n\t\tconst extension = fileName.toLowerCase().split(\".\").pop();\n\n\t\tif (extension && MIME_TYPE_BY_EXTENSION[extension]) {\n\t\t\treturn MIME_TYPE_BY_EXTENSION[extension];\n\t\t}\n\t}\n\n\tif (binaryData) {\n\t\tconst dataUrlMimeType = getDataUrlMimeType(binaryData);\n\n\t\tif (dataUrlMimeType) {\n\t\t\treturn dataUrlMimeType;\n\t\t}\n\n\t\tconst signatureMimeType = detectMimeTypeBySignature(binaryData);\n\n\t\tif (signatureMimeType) {\n\t\t\treturn signatureMimeType;\n\t\t}\n\t}\n\n\treturn DEFAULT_DOCUMENT_MIME_TYPE;\n};\n","import { useEffect, useEffectEvent, useState } from \"react\";\n\nimport { logError } from \"../utils\";\n\nimport { binaryToBlob, detectMimeType } from \"./binaryToBlob\";\n\nexport function useBinaryFile(bin: string | undefined) {\n\tconst [entry, setEntry] = useState<{ blob: Blob; mime: string } | null>(null);\n\tconst [error, setError] = useState<string | null>(null);\n\n\tconst updateEntry = useEffectEvent((data: { blob: Blob; mime: string } | null) => setEntry(data));\n\tconst updateError = useEffectEvent((error: string | null) => setError(error));\n\n\tuseEffect(() => {\n\t\tif (bin) {\n\t\t\ttry {\n\t\t\t\tconst mime = detectMimeType(undefined, bin);\n\t\t\t\tconst blob = binaryToBlob(bin, mime, true);\n\n\t\t\t\tupdateEntry({ blob, mime });\n\t\t\t\tupdateError(null);\n\t\t\t} catch (e) {\n\t\t\t\tlogError(e);\n\n\t\t\t\tupdateError(String(e));\n\t\t\t\tupdateEntry(null);\n\t\t\t}\n\t\t} else {\n\t\t\tupdateEntry(null);\n\t\t\tupdateError(null);\n\t\t}\n\t}, [bin]);\n\n\treturn { data: entry, error };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/binary/binaryToBlob.ts","../../src/binary/getBase64DecodedSize.ts","../../src/binary/useBinaryFile.ts"],"sourcesContent":["const DEFAULT_BLOB_MIME_TYPE = \"application/octet-stream\";\nconst DEFAULT_DOCUMENT_MIME_TYPE = \"application/pdf\";\nconst BASE64_DATA_URL_PATTERN = /^data:([^,]*);base64,([\\s\\S]*)$/i;\n\nconst MIME_TYPE_BY_EXTENSION: Record<string, string> = {\n\tbmp: \"image/bmp\",\n\tcsv: \"text/csv\",\n\tdoc: \"application/msword\",\n\tdocx: \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\tgif: \"image/gif\",\n\tjpeg: \"image/jpeg\",\n\tjpg: \"image/jpeg\",\n\tjson: \"application/json\",\n\tpdf: \"application/pdf\",\n\tpng: \"image/png\",\n\tppt: \"application/vnd.ms-powerpoint\",\n\tpptx: \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\tsvg: \"image/svg+xml\",\n\ttif: \"image/tiff\",\n\ttiff: \"image/tiff\",\n\ttxt: \"text/plain\",\n\twebp: \"image/webp\",\n\txls: \"application/vnd.ms-excel\",\n\txlsx: \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\txml: \"application/xml\",\n\tzip: \"application/zip\"\n};\n\nconst normalizeBase64 = (value: string): string => {\n\tconst normalized = value.replace(/\\s/g, \"\").replace(/-/g, \"+\").replace(/_/g, \"/\");\n\tconst paddingRest = normalized.length % 4;\n\n\tif (paddingRest === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {\n\t\tthrow new Error(\"Некорректный формат base64\");\n\t}\n\n\treturn paddingRest === 0 ? normalized : `${normalized}${\"=\".repeat(4 - paddingRest)}`;\n};\n\nconst getBase64RawPayload = (binaryData: string): { data: string; mimeType: string | null } => {\n\tconst dataUrlMatch = binaryData.match(BASE64_DATA_URL_PATTERN);\n\n\tif (!dataUrlMatch) {\n\t\treturn { data: binaryData, mimeType: null };\n\t}\n\n\tconst [, rawMimeType, rawData] = dataUrlMatch;\n\tconst mimeType = rawMimeType.split(\";\")[0].trim();\n\n\treturn {\n\t\tdata: rawData,\n\t\tmimeType: mimeType || null\n\t};\n};\n\nconst getBase64Payload = (binaryData: string): { data: string; mimeType: string | null } => {\n\tconst payload = getBase64RawPayload(binaryData);\n\n\treturn {\n\t\tdata: normalizeBase64(payload.data),\n\t\tmimeType: payload.mimeType\n\t};\n};\n\nconst getDataUrlMimeType = (binaryData: string): string | null => {\n\tconst dataUrlMatch = binaryData.match(BASE64_DATA_URL_PATTERN);\n\n\tif (!dataUrlMatch) {\n\t\treturn null;\n\t}\n\n\tconst mimeType = dataUrlMatch[1].split(\";\")[0].trim();\n\n\treturn mimeType || null;\n};\n\nconst decodeBase64ToBytes = (binaryData: string): Uint8Array => {\n\tconst { data } = getBase64Payload(binaryData);\n\tconst binaryString = atob(data);\n\tconst byteArray = new Uint8Array(binaryString.length);\n\n\tfor (let index = 0; index < binaryString.length; index++) {\n\t\tbyteArray[index] = binaryString.charCodeAt(index);\n\t}\n\n\treturn byteArray;\n};\n\nconst binaryStringToBytes = (binaryData: string): Uint8Array => {\n\tconst byteArray = new Uint8Array(binaryData.length);\n\n\tfor (let index = 0; index < binaryData.length; index++) {\n\t\tbyteArray[index] = binaryData.charCodeAt(index);\n\t}\n\n\treturn byteArray;\n};\n\nconst copyBytesToArrayBuffer = (byteArray: Uint8Array): ArrayBuffer => {\n\tconst buffer = new ArrayBuffer(byteArray.byteLength);\n\tconst view = new Uint8Array(buffer);\n\n\tview.set(byteArray);\n\n\treturn buffer;\n};\n\nconst decodeHeaderFromBase64 = (binaryData: string): string | null => {\n\ttry {\n\t\tconst { data } = getBase64RawPayload(binaryData);\n\t\tconst headerData = data.slice(0, 128);\n\t\tconst binaryString = atob(normalizeBase64(headerData));\n\n\t\treturn binaryString;\n\t} catch {\n\t\treturn null;\n\t}\n};\n\nconst detectMimeTypeBySignature = (binaryData: string): string | null => {\n\tconst header = decodeHeaderFromBase64(binaryData);\n\n\tif (!header) {\n\t\treturn null;\n\t}\n\n\tconst bytes = new Uint8Array(header.length);\n\n\tfor (let index = 0; index < header.length; index++) {\n\t\tbytes[index] = header.charCodeAt(index);\n\t}\n\n\tif (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {\n\t\treturn \"application/pdf\";\n\t}\n\n\tif (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {\n\t\treturn \"image/jpeg\";\n\t}\n\n\tif (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {\n\t\treturn \"image/png\";\n\t}\n\n\tif (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {\n\t\treturn \"image/gif\";\n\t}\n\n\tif (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {\n\t\treturn \"image/webp\";\n\t}\n\n\tif (bytes[0] === 0x50 && bytes[1] === 0x4b) {\n\t\treturn \"application/zip\";\n\t}\n\n\tconst textHeader = header\n\t\t.replace(/^\\uFEFF/, \"\")\n\t\t.trimStart()\n\t\t.slice(0, 16);\n\n\tif (textHeader.startsWith(\"<?xml\") || textHeader.startsWith(\"<\")) {\n\t\treturn \"application/xml\";\n\t}\n\n\treturn null;\n};\n\n/**\n * Преобразует base64 или бинарную строку в Blob.\n *\n * Data URL с префиксом `data:*;base64,` поддерживается, сам URL здесь не создаётся.\n */\nexport const binaryToBlob = (binaryData: string, mimeType: string = DEFAULT_BLOB_MIME_TYPE, isBase64 = true): Blob => {\n\tif (!binaryData) {\n\t\tthrow new Error(\"Не переданы бинарные данные\");\n\t}\n\n\ttry {\n\t\tconst byteArray = isBase64 ? decodeBase64ToBytes(binaryData) : binaryStringToBytes(binaryData);\n\n\t\tif (byteArray.length === 0) {\n\t\t\tthrow new Error(\"После преобразования не осталось данных\");\n\t\t}\n\n\t\treturn new Blob([copyBytesToArrayBuffer(byteArray)], { type: mimeType.trim() || DEFAULT_BLOB_MIME_TYPE });\n\t} catch (error) {\n\t\tif (error instanceof Error) {\n\t\t\tthrow error;\n\t\t}\n\t\tthrow new Error(\"Некорректный формат бинарных данных\");\n\t}\n};\n\n/**\n * Определяет MIME-тип по имени файла, data URL или сигнатуре base64-данных.\n *\n * Если определить тип не удалось, используется PDF: большинство документов в этом контуре приходят именно так.\n */\nexport const detectMimeType = (fileName?: string, binaryData?: string): string => {\n\tif (fileName) {\n\t\tconst extension = fileName.toLowerCase().split(\".\").pop();\n\n\t\tif (extension && MIME_TYPE_BY_EXTENSION[extension]) {\n\t\t\treturn MIME_TYPE_BY_EXTENSION[extension];\n\t\t}\n\t}\n\n\tif (binaryData) {\n\t\tconst dataUrlMimeType = getDataUrlMimeType(binaryData);\n\n\t\tif (dataUrlMimeType) {\n\t\t\treturn dataUrlMimeType;\n\t\t}\n\n\t\tconst signatureMimeType = detectMimeTypeBySignature(binaryData);\n\n\t\tif (signatureMimeType) {\n\t\t\treturn signatureMimeType;\n\t\t}\n\t}\n\n\treturn DEFAULT_DOCUMENT_MIME_TYPE;\n};\n","const INVALID_BASE64_CHARACTER_PATTERN = /[^A-Za-z0-9+/=]/;\n\n/**\n * Проверяет стандартную Base64-форму и считает байты без декодирования или создания Buffer/Blob.\n * Невалидный payload возвращает null, пустая строка — 0; допустимый размер определяет вызывающий контракт.\n * Data URL, whitespace, URL-safe алфавит и отсутствующий padding здесь не нормализуются.\n */\nexport function getBase64DecodedSize(value: string): number | null {\n\tif (value.length % 4 !== 0 || INVALID_BASE64_CHARACTER_PATTERN.test(value)) return null;\n\tconst paddingLength = value.endsWith(\"==\") ? 2 : value.endsWith(\"=\") ? 1 : 0;\n\tconst firstPaddingIndex = value.indexOf(\"=\");\n\tif (firstPaddingIndex !== -1 && firstPaddingIndex !== value.length - paddingLength) return null;\n\treturn Math.floor((value.length * 3) / 4) - paddingLength;\n}\n","import { useEffect, useEffectEvent, useState } from \"react\";\n\nimport { logError } from \"../utils\";\n\nimport { binaryToBlob, detectMimeType } from \"./binaryToBlob\";\n\nexport function useBinaryFile(bin: string | undefined) {\n\tconst [entry, setEntry] = useState<{ blob: Blob; mime: string } | null>(null);\n\tconst [error, setError] = useState<string | null>(null);\n\n\tconst updateEntry = useEffectEvent((data: { blob: Blob; mime: string } | null) => setEntry(data));\n\tconst updateError = useEffectEvent((error: string | null) => setError(error));\n\n\tuseEffect(() => {\n\t\tif (bin) {\n\t\t\ttry {\n\t\t\t\tconst mime = detectMimeType(undefined, bin);\n\t\t\t\tconst blob = binaryToBlob(bin, mime, true);\n\n\t\t\t\tupdateEntry({ blob, mime });\n\t\t\t\tupdateError(null);\n\t\t\t} catch (e) {\n\t\t\t\tlogError(e);\n\n\t\t\t\tupdateError(String(e));\n\t\t\t\tupdateEntry(null);\n\t\t\t}\n\t\t} else {\n\t\t\tupdateEntry(null);\n\t\t\tupdateError(null);\n\t\t}\n\t}, [bin]);\n\n\treturn { data: entry, error };\n}\n"],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ /** Определяет, какую существующую часть пути дополнительно проверить через Node.js realpath. */
2
+ export type PathRealpathMode = "none" | "parent" | "target";
3
+ /** Root принадлежит серверной конфигурации; сегменты обозначают только путь внутри него. */
4
+ export type ResolvePathInsideRootOptions = Readonly<{
5
+ rootPath: string;
6
+ pathSegments: readonly string[];
7
+ /** По умолчанию проверяется существующий target, включая символические ссылки. */
8
+ realpathMode?: PathRealpathMode;
9
+ }>;
10
+ //# sourceMappingURL=fileSystemTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fileSystemTypes.d.ts","sourceRoot":"","sources":["../../src/file-system/fileSystemTypes.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,4FAA4F;AAC5F,MAAM,MAAM,4BAA4B,GAAG,QAAQ,CAAC;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,kFAAkF;IAClF,YAAY,CAAC,EAAE,gBAAgB,CAAC;CAChC,CAAC,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { type PathRealpathMode, type ResolvePathInsideRootOptions } from "./fileSystemTypes";
2
+ export { isFileSystemNotFoundError } from "./isFileSystemNotFoundError";
3
+ export { resolvePathInsideRoot } from "./resolvePathInsideRoot";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/file-system/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,4BAA4B,EAAE,MAAM,mBAAmB,CAAC;AAC7F,OAAO,EAAE,yBAAyB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,37 @@
1
+ import { i as e } from "../chunks/isRecord-DXDTW-jA.js";
2
+ import { realpath as t } from "node:fs/promises";
3
+ import { dirname as n, isAbsolute as r, relative as i, resolve as a, sep as o } from "node:path";
4
+ //#region src/file-system/isFileSystemNotFoundError.ts
5
+ function s(t) {
6
+ return e(t) ? t.code === "ENOENT" || t.code === "ENOTDIR" : !1;
7
+ }
8
+ //#endregion
9
+ //#region src/file-system/resolvePathInsideRoot.ts
10
+ function c(e) {
11
+ let t = e.trim();
12
+ if (!t) throw Error("Корневая папка хранилища не задана.");
13
+ return a(t);
14
+ }
15
+ function l(e, t) {
16
+ let n = i(e, t);
17
+ return n === "" || n !== ".." && !n.startsWith(`..${o}`) && !r(n);
18
+ }
19
+ async function u(e, r, i) {
20
+ let a;
21
+ try {
22
+ a = await t(e);
23
+ } catch (e) {
24
+ throw Error("Корневая папка хранилища недоступна.", { cause: e });
25
+ }
26
+ let o = i === "parent" ? n(r) : r, s = await t(o);
27
+ if (!l(a, s)) throw Error("Путь выходит за пределы корневой папки хранилища.");
28
+ }
29
+ async function d({ rootPath: e, pathSegments: t, realpathMode: n = "target" }) {
30
+ let r = c(e), i = a(r, ...t);
31
+ if (!l(r, i)) throw Error("Путь выходит за пределы корневой папки хранилища.");
32
+ return n !== "none" && await u(r, i, n), i;
33
+ }
34
+ //#endregion
35
+ export { s as isFileSystemNotFoundError, d as resolvePathInsideRoot };
36
+
37
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/file-system/isFileSystemNotFoundError.ts","../../src/file-system/resolvePathInsideRoot.ts"],"sourcesContent":["import { isRecord } from \"../validators\";\n\n/** Отличает отсутствующий путь от ошибок доступа и I/O, которые вызывающий код обязан обработать отдельно. */\nexport function isFileSystemNotFoundError(error: unknown): boolean {\n\tif (!isRecord(error)) return false;\n\treturn error.code === \"ENOENT\" || error.code === \"ENOTDIR\";\n}\n","import { type PathRealpathMode, type ResolvePathInsideRootOptions } from \"./fileSystemTypes\";\n\nimport { realpath } from \"node:fs/promises\";\nimport { dirname, isAbsolute, relative, resolve, sep } from \"node:path\";\n\nfunction resolveStorageRoot(rootPath: string): string {\n\tconst normalizedRootPath = rootPath.trim();\n\tif (!normalizedRootPath) {\n\t\tthrow new Error(\"Корневая папка хранилища не задана.\");\n\t}\n\treturn resolve(normalizedRootPath);\n}\n\nfunction isResolvedPathInsideRoot(rootPath: string, candidatePath: string): boolean {\n\tconst relativePath = relative(rootPath, candidatePath);\n\t// Проверяется сегмент родителя, а не префикс имени: '..metadata' является допустимым файлом внутри root.\n\treturn relativePath === \"\" || (relativePath !== \"..\" && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath));\n}\n\nasync function assertRealPathInsideRoot(\n\trootPath: string,\n\tcandidatePath: string,\n\trealpathMode: Exclude<PathRealpathMode, \"none\">\n): Promise<void> {\n\tlet realRootPath: string;\n\ttry {\n\t\trealRootPath = await realpath(rootPath);\n\t} catch (error) {\n\t\tthrow new Error(\"Корневая папка хранилища недоступна.\", { cause: error });\n\t}\n\tconst pathForRealCheck = realpathMode === \"parent\" ? dirname(candidatePath) : candidatePath;\n\tconst realCandidatePath = await realpath(pathForRealCheck);\n\tif (!isResolvedPathInsideRoot(realRootPath, realCandidatePath)) {\n\t\tthrow new Error(\"Путь выходит за пределы корневой папки хранилища.\");\n\t}\n}\n\n/**\n * Нормализует абсолютный путь и запрещает выход за доверенный root.\n * 'target' проверяет существующий файл, 'parent' — каталог перед созданием файла,\n * 'none' выполняет только лексическую проверку и не защищает от символических ссылок.\n * Проверка не открывает файл: защиту от смены пути между проверкой и I/O обеспечивает вызывающий код.\n */\nexport async function resolvePathInsideRoot({\n\trootPath,\n\tpathSegments,\n\trealpathMode = \"target\"\n}: ResolvePathInsideRootOptions): Promise<string> {\n\tconst resolvedRootPath = resolveStorageRoot(rootPath);\n\tconst resolvedCandidatePath = resolve(resolvedRootPath, ...pathSegments);\n\tif (!isResolvedPathInsideRoot(resolvedRootPath, resolvedCandidatePath)) {\n\t\tthrow new Error(\"Путь выходит за пределы корневой папки хранилища.\");\n\t}\n\tif (realpathMode !== \"none\") {\n\t\tawait assertRealPathInsideRoot(resolvedRootPath, resolvedCandidatePath, realpathMode);\n\t}\n\treturn resolvedCandidatePath;\n}\n"],"mappings":";;;;AAGA,SAAgB,EAA0B,GAAyB;CAElE,OADK,EAAS,CAAK,IACZ,EAAM,SAAS,YAAY,EAAM,SAAS,YADpB;AAE9B;;;ACDA,SAAS,EAAmB,GAA0B;CACrD,IAAM,IAAqB,EAAS,KAAK;CACzC,IAAI,CAAC,GACJ,MAAU,MAAM,qCAAqC;CAEtD,OAAO,EAAQ,CAAkB;AAClC;AAEA,SAAS,EAAyB,GAAkB,GAAgC;CACnF,IAAM,IAAe,EAAS,GAAU,CAAa;CAErD,OAAO,MAAiB,MAAO,MAAiB,QAAQ,CAAC,EAAa,WAAW,KAAK,GAAK,KAAK,CAAC,EAAW,CAAY;AACzH;AAEA,eAAe,EACd,GACA,GACA,GACgB;CAChB,IAAI;CACJ,IAAI;EACH,IAAe,MAAM,EAAS,CAAQ;CACvC,SAAS,GAAO;EACf,MAAU,MAAM,wCAAwC,EAAE,OAAO,EAAM,CAAC;CACzE;CACA,IAAM,IAAmB,MAAiB,WAAW,EAAQ,CAAa,IAAI,GACxE,IAAoB,MAAM,EAAS,CAAgB;CACzD,IAAI,CAAC,EAAyB,GAAc,CAAiB,GAC5D,MAAU,MAAM,mDAAmD;AAErE;AAQA,eAAsB,EAAsB,EAC3C,aACA,iBACA,kBAAe,YACkC;CACjD,IAAM,IAAmB,EAAmB,CAAQ,GAC9C,IAAwB,EAAQ,GAAkB,GAAG,CAAY;CACvE,IAAI,CAAC,EAAyB,GAAkB,CAAqB,GACpE,MAAU,MAAM,mDAAmD;CAKpE,OAHI,MAAiB,UACpB,MAAM,EAAyB,GAAkB,GAAuB,CAAY,GAE9E;AACR"}
@@ -0,0 +1,3 @@
1
+ /** Отличает отсутствующий путь от ошибок доступа и I/O, которые вызывающий код обязан обработать отдельно. */
2
+ export declare function isFileSystemNotFoundError(error: unknown): boolean;
3
+ //# sourceMappingURL=isFileSystemNotFoundError.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isFileSystemNotFoundError.d.ts","sourceRoot":"","sources":["../../src/file-system/isFileSystemNotFoundError.ts"],"names":[],"mappings":"AAEA,8GAA8G;AAC9G,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAGjE"}
@@ -0,0 +1,9 @@
1
+ import { type ResolvePathInsideRootOptions } from "./fileSystemTypes";
2
+ /**
3
+ * Нормализует абсолютный путь и запрещает выход за доверенный root.
4
+ * 'target' проверяет существующий файл, 'parent' — каталог перед созданием файла,
5
+ * 'none' выполняет только лексическую проверку и не защищает от символических ссылок.
6
+ * Проверка не открывает файл: защиту от смены пути между проверкой и I/O обеспечивает вызывающий код.
7
+ */
8
+ export declare function resolvePathInsideRoot({ rootPath, pathSegments, realpathMode }: ResolvePathInsideRootOptions): Promise<string>;
9
+ //# sourceMappingURL=resolvePathInsideRoot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolvePathInsideRoot.d.ts","sourceRoot":"","sources":["../../src/file-system/resolvePathInsideRoot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,KAAK,4BAA4B,EAAE,MAAM,mBAAmB,CAAC;AAqC7F;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAAC,EAC3C,QAAQ,EACR,YAAY,EACZ,YAAuB,EACvB,EAAE,4BAA4B,GAAG,OAAO,CAAC,MAAM,CAAC,CAUhD"}
@@ -89,35 +89,44 @@ function s() {
89
89
  createdAt: l,
90
90
  ttlMs: t.ttlMs,
91
91
  dismissible: t.dismissible ?? !0,
92
- actions: t.actions
92
+ actions: t.actions,
93
+ ...t.retainInHistory === void 0 ? {} : { retainInHistory: t.retainInHistory }
93
94
  }, d = s().items, f = [u, ...d.filter((e) => e.id !== c)].slice(0, o), p = new Set(f.map((e) => e.id));
94
95
  return d.forEach((e) => {
95
96
  p.has(e.id) || n(e.id);
96
- }), e((e) => ({
97
- items: f,
98
- history: [u, ...e.history.filter((e) => e.id !== c)]
99
- })), r(i, c, u.ttlMs), c;
97
+ }), e((e) => {
98
+ let t = e.history;
99
+ return u.retainInHistory === !1 ? e.history.some((e) => e.id === c) && (t = e.history.filter((e) => e.id !== c)) : t = [u, ...e.history.filter((e) => e.id !== c)], {
100
+ items: f,
101
+ history: t
102
+ };
103
+ }), r(i, c, u.ttlMs), c;
100
104
  },
101
105
  update: (t, n) => {
102
106
  let a = s().items.find((e) => e.id === t);
103
107
  if (!a) return !1;
104
- let o = {
108
+ let { retainInHistory: o, ...c } = n, l = {
105
109
  ...a,
106
- ...n,
110
+ ...c,
111
+ ...o === void 0 ? {} : { retainInHistory: o },
107
112
  id: a.id,
108
113
  createdAt: a.createdAt
109
114
  };
110
- return e((e) => ({
111
- items: e.items.map((e) => e.id === t ? o : e),
112
- history: e.history.map((e) => e.id === t ? o : e)
113
- })), "ttlMs" in n && r(i, t, o.ttlMs), !0;
115
+ return e((e) => {
116
+ let n = e.history.some((e) => e.id === t), r = e.history;
117
+ return l.retainInHistory === !1 ? n && (r = e.history.filter((e) => e.id !== t)) : n ? r = e.history.map((e) => e.id === t ? l : e) : o === !0 && (r = [l, ...e.history]), {
118
+ items: e.items.map((e) => e.id === t ? l : e),
119
+ history: r
120
+ };
121
+ }), "ttlMs" in n && r(i, t, l.ttlMs), !0;
114
122
  },
115
123
  upsert: (e) => s().actions.update(e.id, {
116
124
  type: e.type,
117
125
  title: e.title,
118
126
  message: e.message,
119
127
  ttlMs: e.ttlMs,
120
- dismissible: e.dismissible
128
+ dismissible: e.dismissible,
129
+ ...e.retainInHistory === void 0 ? {} : { retainInHistory: e.retainInHistory }
121
130
  }) ? e.id : s().actions.push(e),
122
131
  dismiss: (t) => {
123
132
  n(t), e((e) => ({ items: e.items.filter((e) => e.id !== t) }));
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/notifications/notify.ts","../../src/notifications/store.ts"],"sourcesContent":["import { type NotificationPushInput, type NotificationsStoreApi, type NotificationUpdatePatch } from \"./store\";\nimport { type NotificationId } from \"./types\";\n\nlet boundStore: NotificationsStoreApi | null = null;\n\nexport const bindNotifications = (store: NotificationsStoreApi) => {\n\tboundStore = store;\n\n\treturn () => {\n\t\tif (boundStore === store) {\n\t\t\tboundStore = null;\n\t\t}\n\t};\n};\n\nconst requireStore = (): NotificationsStoreApi => {\n\tif (!boundStore) throw new Error(\"Notifications store is not bound. Wrap app with NotificationsProvider.\");\n\treturn boundStore;\n};\n\nexport const notify = Object.freeze({\n\tpush: (input: NotificationPushInput) => requireStore().getState().actions.push(input),\n\tupsert: (input: NotificationPushInput & { id: NotificationId }) => requireStore().getState().actions.upsert(input),\n\tupdate: (id: NotificationId, patch: NotificationUpdatePatch) => requireStore().getState().actions.update(id, patch),\n\n\tdismiss: (id: NotificationId) => requireStore().getState().actions.dismiss(id),\n\tclear: () => requireStore().getState().actions.clear(),\n\tclearHistory: () => requireStore().getState().actions.clearHistory(),\n\n\tsuccess: (message: string, opts?: Omit<NotificationPushInput, \"type\" | \"message\">) =>\n\t\trequireStore()\n\t\t\t.getState()\n\t\t\t.actions.push({ type: \"success\", message, ...opts }),\n\tinfo: (message: string, opts?: Omit<NotificationPushInput, \"type\" | \"message\">) =>\n\t\trequireStore()\n\t\t\t.getState()\n\t\t\t.actions.push({ type: \"info\", message, ...opts }),\n\twarning: (message: string, opts?: Omit<NotificationPushInput, \"type\" | \"message\">) =>\n\t\trequireStore()\n\t\t\t.getState()\n\t\t\t.actions.push({ type: \"warning\", message, ...opts }),\n\terror: (message: string, opts?: Omit<NotificationPushInput, \"type\" | \"message\">) =>\n\t\trequireStore()\n\t\t\t.getState()\n\t\t\t.actions.push({ type: \"error\", message, ...opts }),\n\n\t/**\n\t * Удобный паттерн \"долгой операции\":\n\t * const op = notify.progress(\"Сохраняю...\");\n\t * ... op.success(\"Готово\") или op.error(\"Ошибка\")\n\t */\n\tprogress: (message: string, opts?: { id?: NotificationId; title?: string }) => {\n\t\tconst store = requireStore();\n\t\tconst id = opts?.id;\n\n\t\tif (id) {\n\t\t\tstore.getState().actions.upsert({\n\t\t\t\tid,\n\t\t\t\ttype: \"info\",\n\t\t\t\ttitle: opts?.title,\n\t\t\t\tmessage,\n\t\t\t\tdismissible: false\n\t\t\t});\n\t\t}\n\n\t\tconst notificationId =\n\t\t\tid ??\n\t\t\tstore.getState().actions.push({\n\t\t\t\ttype: \"info\",\n\t\t\t\ttitle: opts?.title,\n\t\t\t\tmessage,\n\t\t\t\tdismissible: false\n\t\t\t});\n\n\t\treturn {\n\t\t\tid: notificationId,\n\t\t\tupdate: (nextMessage: string) => notify.update(notificationId, { message: nextMessage }),\n\t\t\tsuccess: (nextMessage: string, ttlMs = 2500) =>\n\t\t\t\tnotify.update(notificationId, { type: \"success\", message: nextMessage, dismissible: true, ttlMs }),\n\t\t\terror: (nextMessage: string, ttlMs = 5000) =>\n\t\t\t\tnotify.update(notificationId, { type: \"error\", message: nextMessage, dismissible: true, ttlMs }),\n\t\t\tdismiss: () => notify.dismiss(notificationId)\n\t\t};\n\t}\n});\n","import { createStore, type StoreApi } from \"zustand\";\n\nimport { type Notification, type NotificationAction, type NotificationId, type NotificationType } from \"./types\";\n\nexport type NotificationPushInput = {\n\tid?: NotificationId;\n\ttype: NotificationType;\n\ttitle?: string;\n\tmessage: string;\n\tttlMs?: number;\n\tdismissible?: boolean;\n\tactions?: NotificationAction[];\n};\n\nexport type NotificationUpdatePatch = Partial<Omit<Notification, \"id\" | \"createdAt\">> & {\n\t// ttlMs можно менять; createdAt — нет\n};\n\nexport type NotificationsState = {\n\t/** Активные toast-уведомления, которые должен отображать краткоживущий host. */\n\titems: Notification[];\n\t/** Полная история за время жизни store; TTL, dismiss и переполнение toast-стека её не сокращают. */\n\thistory: Notification[];\n};\n\nexport type NotificationsActions = {\n\tpush: (input: NotificationPushInput) => NotificationId;\n\tupdate: (id: NotificationId, patch: NotificationUpdatePatch) => boolean;\n\tupsert: (input: NotificationPushInput & { id: NotificationId }) => NotificationId;\n\tdismiss: (id: NotificationId) => void;\n\tclear: () => void;\n\tclearHistory: () => void;\n};\n\nexport type NotificationsStore = NotificationsState & {\n\tactions: NotificationsActions;\n};\n\nconst genId = (): NotificationId => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;\n\nconst MAX_VISIBLE_ITEMS = 6;\n\nexport function createNotificationsStore() {\n\tconst timers = new Map<NotificationId, number>();\n\n\tconst clearTimer = (id: NotificationId) => {\n\t\tconst t = timers.get(id);\n\t\tif (t) {\n\t\t\twindow.clearTimeout(t);\n\t\t\ttimers.delete(id);\n\t\t}\n\t};\n\n\tconst armTimer = (store: StoreApi<NotificationsStore>, id: NotificationId, ttlMs: number = 10_000) => {\n\t\tclearTimer(id);\n\t\tif (ttlMs <= 0) return;\n\n\t\tconst t = window.setTimeout(() => {\n\t\t\tstore.getState().actions.dismiss(id);\n\t\t}, ttlMs);\n\n\t\ttimers.set(id, t);\n\t};\n\n\tconst store = createStore<NotificationsStore>()((set, get) => ({\n\t\titems: [],\n\t\thistory: [],\n\n\t\tactions: {\n\t\t\tpush: (input) => {\n\t\t\t\tconst id = input.id ?? genId();\n\t\t\t\tconst now = Date.now();\n\n\t\t\t\tconst notif: Notification = {\n\t\t\t\t\tid,\n\t\t\t\t\ttype: input.type,\n\t\t\t\t\ttitle: input.title,\n\t\t\t\t\tmessage: input.message,\n\t\t\t\t\tcreatedAt: now,\n\t\t\t\t\tttlMs: input.ttlMs,\n\t\t\t\t\tdismissible: input.dismissible ?? true,\n\t\t\t\t\tactions: input.actions\n\t\t\t\t};\n\n\t\t\t\tconst currentItems = get().items;\n\t\t\t\tconst nextItems = [notif, ...currentItems.filter((notification) => notification.id !== id)].slice(0, MAX_VISIBLE_ITEMS);\n\t\t\t\tconst visibleIds = new Set(nextItems.map((notification) => notification.id));\n\n\t\t\t\t// Для вытесненного toast таймер больше не нужен: запись уже останется в history.\n\t\t\t\tcurrentItems.forEach((notification) => {\n\t\t\t\t\tif (!visibleIds.has(notification.id)) clearTimer(notification.id);\n\t\t\t\t});\n\n\t\t\t\tset((state) => ({\n\t\t\t\t\titems: nextItems,\n\t\t\t\t\thistory: [notif, ...state.history.filter((notification) => notification.id !== id)]\n\t\t\t\t}));\n\n\t\t\t\tarmTimer(store, id, notif.ttlMs);\n\t\t\t\treturn id;\n\t\t\t},\n\n\t\t\tupdate: (id, patch) => {\n\t\t\t\tconst previous = get().items.find((notification) => notification.id === id);\n\t\t\t\tif (!previous) return false;\n\n\t\t\t\tconst next: Notification = {\n\t\t\t\t\t...previous,\n\t\t\t\t\t...patch,\n\t\t\t\t\tid: previous.id,\n\t\t\t\t\tcreatedAt: previous.createdAt\n\t\t\t\t};\n\n\t\t\t\tset((state) => ({\n\t\t\t\t\titems: state.items.map((notification) => (notification.id === id ? next : notification)),\n\t\t\t\t\thistory: state.history.map((notification) => (notification.id === id ? next : notification))\n\t\t\t\t}));\n\n\t\t\t\t// если ttlMs изменили — пере-армим таймер\n\t\t\t\tif (\"ttlMs\" in patch) {\n\t\t\t\t\tarmTimer(store, id, next.ttlMs);\n\t\t\t\t}\n\n\t\t\t\treturn true;\n\t\t\t},\n\n\t\t\tupsert: (input) => {\n\t\t\t\tconst ok = get().actions.update(input.id, {\n\t\t\t\t\ttype: input.type,\n\t\t\t\t\ttitle: input.title,\n\t\t\t\t\tmessage: input.message,\n\t\t\t\t\tttlMs: input.ttlMs,\n\t\t\t\t\tdismissible: input.dismissible\n\t\t\t\t});\n\n\t\t\t\tif (ok) return input.id;\n\t\t\t\treturn get().actions.push(input);\n\t\t\t},\n\n\t\t\tdismiss: (id) => {\n\t\t\t\tclearTimer(id);\n\t\t\t\tset((s) => ({ items: s.items.filter((n) => n.id !== id) }));\n\t\t\t},\n\n\t\t\tclear: () => {\n\t\t\t\ttimers.forEach((t) => window.clearTimeout(t));\n\t\t\t\ttimers.clear();\n\t\t\t\tset({ items: [] });\n\t\t\t},\n\n\t\t\tclearHistory: () => {\n\t\t\t\tset({ history: [] });\n\t\t\t}\n\t\t}\n\t}));\n\n\treturn store;\n}\n\nexport type NotificationsStoreApi = StoreApi<NotificationsStore>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/notifications/notify.ts","../../src/notifications/store.ts"],"sourcesContent":["import { type NotificationPushInput, type NotificationsStoreApi, type NotificationUpdatePatch } from \"./store\";\nimport { type NotificationId } from \"./types\";\n\nlet boundStore: NotificationsStoreApi | null = null;\n\nexport const bindNotifications = (store: NotificationsStoreApi) => {\n\tboundStore = store;\n\n\treturn () => {\n\t\tif (boundStore === store) {\n\t\t\tboundStore = null;\n\t\t}\n\t};\n};\n\nconst requireStore = (): NotificationsStoreApi => {\n\tif (!boundStore) throw new Error(\"Notifications store is not bound. Wrap app with NotificationsProvider.\");\n\treturn boundStore;\n};\n\nexport const notify = Object.freeze({\n\tpush: (input: NotificationPushInput) => requireStore().getState().actions.push(input),\n\tupsert: (input: NotificationPushInput & { id: NotificationId }) => requireStore().getState().actions.upsert(input),\n\tupdate: (id: NotificationId, patch: NotificationUpdatePatch) => requireStore().getState().actions.update(id, patch),\n\n\tdismiss: (id: NotificationId) => requireStore().getState().actions.dismiss(id),\n\tclear: () => requireStore().getState().actions.clear(),\n\tclearHistory: () => requireStore().getState().actions.clearHistory(),\n\n\tsuccess: (message: string, opts?: Omit<NotificationPushInput, \"type\" | \"message\">) =>\n\t\trequireStore()\n\t\t\t.getState()\n\t\t\t.actions.push({ type: \"success\", message, ...opts }),\n\tinfo: (message: string, opts?: Omit<NotificationPushInput, \"type\" | \"message\">) =>\n\t\trequireStore()\n\t\t\t.getState()\n\t\t\t.actions.push({ type: \"info\", message, ...opts }),\n\twarning: (message: string, opts?: Omit<NotificationPushInput, \"type\" | \"message\">) =>\n\t\trequireStore()\n\t\t\t.getState()\n\t\t\t.actions.push({ type: \"warning\", message, ...opts }),\n\terror: (message: string, opts?: Omit<NotificationPushInput, \"type\" | \"message\">) =>\n\t\trequireStore()\n\t\t\t.getState()\n\t\t\t.actions.push({ type: \"error\", message, ...opts }),\n\n\t/**\n\t * Удобный паттерн \"долгой операции\":\n\t * const op = notify.progress(\"Сохраняю...\");\n\t * ... op.success(\"Готово\") или op.error(\"Ошибка\")\n\t */\n\tprogress: (message: string, opts?: { id?: NotificationId; title?: string }) => {\n\t\tconst store = requireStore();\n\t\tconst id = opts?.id;\n\n\t\tif (id) {\n\t\t\tstore.getState().actions.upsert({\n\t\t\t\tid,\n\t\t\t\ttype: \"info\",\n\t\t\t\ttitle: opts?.title,\n\t\t\t\tmessage,\n\t\t\t\tdismissible: false\n\t\t\t});\n\t\t}\n\n\t\tconst notificationId =\n\t\t\tid ??\n\t\t\tstore.getState().actions.push({\n\t\t\t\ttype: \"info\",\n\t\t\t\ttitle: opts?.title,\n\t\t\t\tmessage,\n\t\t\t\tdismissible: false\n\t\t\t});\n\n\t\treturn {\n\t\t\tid: notificationId,\n\t\t\tupdate: (nextMessage: string) => notify.update(notificationId, { message: nextMessage }),\n\t\t\tsuccess: (nextMessage: string, ttlMs = 2500) =>\n\t\t\t\tnotify.update(notificationId, { type: \"success\", message: nextMessage, dismissible: true, ttlMs }),\n\t\t\terror: (nextMessage: string, ttlMs = 5000) =>\n\t\t\t\tnotify.update(notificationId, { type: \"error\", message: nextMessage, dismissible: true, ttlMs }),\n\t\t\tdismiss: () => notify.dismiss(notificationId)\n\t\t};\n\t}\n});\n","import { createStore, type StoreApi } from \"zustand\";\n\nimport { type Notification, type NotificationAction, type NotificationId, type NotificationType } from \"./types\";\n\nexport type NotificationPushInput = {\n\tid?: NotificationId;\n\ttype: NotificationType;\n\ttitle?: string;\n\tmessage: string;\n\tttlMs?: number;\n\tdismissible?: boolean;\n\tactions?: NotificationAction[];\n\t/** false исключает текст из истории, не меняя отображение и срок жизни активного toast. */\n\tretainInHistory?: boolean;\n};\n\nexport type NotificationUpdatePatch = Partial<Omit<Notification, \"id\" | \"createdAt\">> & {\n\t// ttlMs можно менять; createdAt — нет\n};\n\nexport type NotificationsState = {\n\t/** Активные toast-уведомления, которые должен отображать краткоживущий host. */\n\titems: Notification[];\n\t/** История сохраняемых уведомлений; TTL, dismiss и переполнение toast-стека её не сокращают. */\n\thistory: Notification[];\n};\n\nexport type NotificationsActions = {\n\tpush: (input: NotificationPushInput) => NotificationId;\n\tupdate: (id: NotificationId, patch: NotificationUpdatePatch) => boolean;\n\tupsert: (input: NotificationPushInput & { id: NotificationId }) => NotificationId;\n\tdismiss: (id: NotificationId) => void;\n\tclear: () => void;\n\tclearHistory: () => void;\n};\n\nexport type NotificationsStore = NotificationsState & {\n\tactions: NotificationsActions;\n};\n\nconst genId = (): NotificationId => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;\n\nconst MAX_VISIBLE_ITEMS = 6;\n\nexport function createNotificationsStore() {\n\tconst timers = new Map<NotificationId, number>();\n\n\tconst clearTimer = (id: NotificationId) => {\n\t\tconst t = timers.get(id);\n\t\tif (t) {\n\t\t\twindow.clearTimeout(t);\n\t\t\ttimers.delete(id);\n\t\t}\n\t};\n\n\tconst armTimer = (store: StoreApi<NotificationsStore>, id: NotificationId, ttlMs: number = 10_000) => {\n\t\tclearTimer(id);\n\t\tif (ttlMs <= 0) return;\n\n\t\tconst t = window.setTimeout(() => {\n\t\t\tstore.getState().actions.dismiss(id);\n\t\t}, ttlMs);\n\n\t\ttimers.set(id, t);\n\t};\n\n\tconst store = createStore<NotificationsStore>()((set, get) => ({\n\t\titems: [],\n\t\thistory: [],\n\n\t\tactions: {\n\t\t\tpush: (input) => {\n\t\t\t\tconst id = input.id ?? genId();\n\t\t\t\tconst now = Date.now();\n\n\t\t\t\tconst notif: Notification = {\n\t\t\t\t\tid,\n\t\t\t\t\ttype: input.type,\n\t\t\t\t\ttitle: input.title,\n\t\t\t\t\tmessage: input.message,\n\t\t\t\t\tcreatedAt: now,\n\t\t\t\t\tttlMs: input.ttlMs,\n\t\t\t\t\tdismissible: input.dismissible ?? true,\n\t\t\t\t\tactions: input.actions,\n\t\t\t\t\t// Не добавляем поле старым consumers, если они не задали новую политику.\n\t\t\t\t\t...(input.retainInHistory !== undefined ? { retainInHistory: input.retainInHistory } : {})\n\t\t\t\t};\n\n\t\t\t\tconst currentItems = get().items;\n\t\t\t\tconst nextItems = [notif, ...currentItems.filter((notification) => notification.id !== id)].slice(0, MAX_VISIBLE_ITEMS);\n\t\t\t\tconst visibleIds = new Set(nextItems.map((notification) => notification.id));\n\n\t\t\t\t// Для вытесненного toast таймер больше не нужен; сохранение истории определяется его политикой.\n\t\t\t\tcurrentItems.forEach((notification) => {\n\t\t\t\t\tif (!visibleIds.has(notification.id)) clearTimer(notification.id);\n\t\t\t\t});\n\n\t\t\t\tset((state) => {\n\t\t\t\t\tlet history = state.history;\n\n\t\t\t\t\tif (notif.retainInHistory !== false) {\n\t\t\t\t\t\thistory = [notif, ...state.history.filter((notification) => notification.id !== id)];\n\t\t\t\t\t} else if (state.history.some((notification) => notification.id === id)) {\n\t\t\t\t\t\thistory = state.history.filter((notification) => notification.id !== id);\n\t\t\t\t\t}\n\n\t\t\t\t\treturn { items: nextItems, history };\n\t\t\t\t});\n\n\t\t\t\tarmTimer(store, id, notif.ttlMs);\n\t\t\t\treturn id;\n\t\t\t},\n\n\t\t\tupdate: (id, patch) => {\n\t\t\t\tconst previous = get().items.find((notification) => notification.id === id);\n\t\t\t\tif (!previous) return false;\n\n\t\t\t\tconst { retainInHistory, ...contentPatch } = patch;\n\t\t\t\tconst next: Notification = {\n\t\t\t\t\t...previous,\n\t\t\t\t\t...contentPatch,\n\t\t\t\t\t// undefined, в том числе переданный явно, не отменяет прежний отказ от истории.\n\t\t\t\t\t...(retainInHistory !== undefined ? { retainInHistory } : {}),\n\t\t\t\t\tid: previous.id,\n\t\t\t\t\tcreatedAt: previous.createdAt\n\t\t\t\t};\n\n\t\t\t\tset((state) => {\n\t\t\t\t\tconst existsInHistory = state.history.some((notification) => notification.id === id);\n\t\t\t\t\tlet history = state.history;\n\n\t\t\t\t\tif (next.retainInHistory === false) {\n\t\t\t\t\t\tif (existsInHistory) history = state.history.filter((notification) => notification.id !== id);\n\t\t\t\t\t} else if (existsInHistory) {\n\t\t\t\t\t\thistory = state.history.map((notification) => (notification.id === id ? next : notification));\n\t\t\t\t\t} else if (retainInHistory === true) {\n\t\t\t\t\t\t// После clearHistory вернуть активный toast можно только явным решением consumer.\n\t\t\t\t\t\thistory = [next, ...state.history];\n\t\t\t\t\t}\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\titems: state.items.map((notification) => (notification.id === id ? next : notification)),\n\t\t\t\t\t\thistory\n\t\t\t\t\t};\n\t\t\t\t});\n\n\t\t\t\t// если ttlMs изменили — пере-армим таймер\n\t\t\t\tif (\"ttlMs\" in patch) {\n\t\t\t\t\tarmTimer(store, id, next.ttlMs);\n\t\t\t\t}\n\n\t\t\t\treturn true;\n\t\t\t},\n\n\t\t\tupsert: (input) => {\n\t\t\t\tconst ok = get().actions.update(input.id, {\n\t\t\t\t\ttype: input.type,\n\t\t\t\t\ttitle: input.title,\n\t\t\t\t\tmessage: input.message,\n\t\t\t\t\tttlMs: input.ttlMs,\n\t\t\t\t\tdismissible: input.dismissible,\n\t\t\t\t\t...(input.retainInHistory !== undefined ? { retainInHistory: input.retainInHistory } : {})\n\t\t\t\t});\n\n\t\t\t\tif (ok) return input.id;\n\t\t\t\treturn get().actions.push(input);\n\t\t\t},\n\n\t\t\tdismiss: (id) => {\n\t\t\t\tclearTimer(id);\n\t\t\t\tset((s) => ({ items: s.items.filter((n) => n.id !== id) }));\n\t\t\t},\n\n\t\t\tclear: () => {\n\t\t\t\ttimers.forEach((t) => window.clearTimeout(t));\n\t\t\t\ttimers.clear();\n\t\t\t\tset({ items: [] });\n\t\t\t},\n\n\t\t\tclearHistory: () => {\n\t\t\t\tset({ history: [] });\n\t\t\t}\n\t\t}\n\t}));\n\n\treturn store;\n}\n\nexport type NotificationsStoreApi = StoreApi<NotificationsStore>;\n"],"mappings":"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"}
@@ -8,12 +8,14 @@ export type NotificationPushInput = {
8
8
  ttlMs?: number;
9
9
  dismissible?: boolean;
10
10
  actions?: NotificationAction[];
11
+ /** false исключает текст из истории, не меняя отображение и срок жизни активного toast. */
12
+ retainInHistory?: boolean;
11
13
  };
12
14
  export type NotificationUpdatePatch = Partial<Omit<Notification, "id" | "createdAt">> & {};
13
15
  export type NotificationsState = {
14
16
  /** Активные toast-уведомления, которые должен отображать краткоживущий host. */
15
17
  items: Notification[];
16
- /** Полная история за время жизни store; TTL, dismiss и переполнение toast-стека её не сокращают. */
18
+ /** История сохраняемых уведомлений; TTL, dismiss и переполнение toast-стека её не сокращают. */
17
19
  history: Notification[];
18
20
  };
19
21
  export type NotificationsActions = {
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/notifications/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,SAAS,CAAC;AAErD,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,KAAK,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEjH,MAAM,MAAM,qBAAqB,GAAG;IACnC,EAAE,CAAC,EAAE,cAAc,CAAC;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,OAAO,CAAC,EAAE,kBAAkB,EAAE,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,WAAW,CAAC,CAAC,GAAG,EAEvF,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAChC,gFAAgF;IAChF,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,oGAAoG;IACpG,OAAO,EAAE,YAAY,EAAE,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IAClC,IAAI,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,cAAc,CAAC;IACvD,MAAM,EAAE,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,uBAAuB,KAAK,OAAO,CAAC;IACxE,MAAM,EAAE,CAAC,KAAK,EAAE,qBAAqB,GAAG;QAAE,EAAE,EAAE,cAAc,CAAA;KAAE,KAAK,cAAc,CAAC;IAClF,OAAO,EAAE,CAAC,EAAE,EAAE,cAAc,KAAK,IAAI,CAAC;IACtC,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,YAAY,EAAE,MAAM,IAAI,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,kBAAkB,GAAG;IACrD,OAAO,EAAE,oBAAoB,CAAC;CAC9B,CAAC;AAMF,wBAAgB,wBAAwB,iCAmHvC;AAED,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC,kBAAkB,CAAC,CAAC"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/notifications/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,SAAS,CAAC;AAErD,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,KAAK,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEjH,MAAM,MAAM,qBAAqB,GAAG;IACnC,EAAE,CAAC,EAAE,cAAc,CAAC;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,OAAO,CAAC,EAAE,kBAAkB,EAAE,CAAC;IAC/B,2FAA2F;IAC3F,eAAe,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,WAAW,CAAC,CAAC,GAAG,EAEvF,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAChC,gFAAgF;IAChF,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,gGAAgG;IAChG,OAAO,EAAE,YAAY,EAAE,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IAClC,IAAI,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,cAAc,CAAC;IACvD,MAAM,EAAE,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,uBAAuB,KAAK,OAAO,CAAC;IACxE,MAAM,EAAE,CAAC,KAAK,EAAE,qBAAqB,GAAG;QAAE,EAAE,EAAE,cAAc,CAAA;KAAE,KAAK,cAAc,CAAC;IAClF,OAAO,EAAE,CAAC,EAAE,EAAE,cAAc,KAAK,IAAI,CAAC;IACtC,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,YAAY,EAAE,MAAM,IAAI,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,kBAAkB,GAAG;IACrD,OAAO,EAAE,oBAAoB,CAAC;CAC9B,CAAC;AAMF,wBAAgB,wBAAwB,iCA8IvC;AAED,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC,kBAAkB,CAAC,CAAC"}
@@ -15,5 +15,7 @@ export type Notification = {
15
15
  ttlMs?: number;
16
16
  dismissible?: boolean;
17
17
  actions?: NotificationAction[];
18
+ /** false оставляет текст только в активном toast; отсутствие поля сохраняет прежнее включение в историю. */
19
+ retainInHistory?: boolean;
18
20
  };
19
21
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/notifications/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;AAExE,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,gBAAgB,CAAC;AAE5D,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC;AAEpC,MAAM,MAAM,kBAAkB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IAC1B,EAAE,EAAE,cAAc,CAAC;IACnB,IAAI,EAAE,gBAAgB,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,OAAO,CAAC,EAAE,kBAAkB,EAAE,CAAC;CAC/B,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/notifications/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;AAExE,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,gBAAgB,CAAC;AAE5D,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC;AAEpC,MAAM,MAAM,kBAAkB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,gBAAgB,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IAC1B,EAAE,EAAE,cAAc,CAAC;IACnB,IAAI,EAAE,gBAAgB,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,OAAO,CAAC,EAAE,kBAAkB,EAAE,CAAC;IAC/B,4GAA4G;IAC5G,eAAe,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuzaki13/react-foundation-lib",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "description": "Reusable React and TypeScript foundation utilities, hooks, and primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Ryuzaki13",
@@ -97,6 +97,10 @@
97
97
  "types": "./dist/file/index.d.ts",
98
98
  "import": "./dist/file/index.js"
99
99
  },
100
+ "./file-system": {
101
+ "types": "./dist/file-system/index.d.ts",
102
+ "import": "./dist/file-system/index.js"
103
+ },
100
104
  "./fingerprint": {
101
105
  "types": "./dist/fingerprint/index.d.ts",
102
106
  "import": "./dist/fingerprint/index.js"
@@ -4,7 +4,7 @@ import { Meta } from "@storybook/addon-docs/blocks";
4
4
 
5
5
  # Работа с бинарными данными
6
6
 
7
- Модуль `binary` преобразует строковое бинарное содержимое в браузерный `Blob`, определяет предполагаемый MIME-тип документа и предоставляет React hook для типового сценария «base64 → `{ blob, mime }`».
7
+ Модуль `binary` преобразует строковое бинарное содержимое в браузерный `Blob`, определяет предполагаемый MIME-тип документа и предоставляет React hook для типового сценария «base64 → `{ blob, mime }`». Отдельная pure function `getBase64DecodedSize` проверяет стандартный Base64 payload и считает размер без декодирования в browser или server runtime.
8
8
 
9
9
  Основной сценарий:
10
10
 
@@ -20,6 +20,7 @@ import { Meta } from "@storybook/addon-docs/blocks";
20
20
 
21
21
  - [Назначение и границы модуля](#назначение-и-границы-модуля)
22
22
  - [Импорт](#импорт)
23
+ - [`getBase64DecodedSize`](#getbase64decodedsize)
23
24
  - [Минимальные понятия](#минимальные-понятия)
24
25
  - [Как выбрать API](#как-выбрать-api)
25
26
  - [Быстрый старт](#быстрый-старт)
@@ -44,7 +45,8 @@ import { Meta } from "@storybook/addon-docs/blocks";
44
45
  - нормализует пробелы, URL-safe алфавит и отсутствующий base64 padding;
45
46
  - создаёт `Blob`;
46
47
  - предполагает MIME по имени файла, объявлению data URL или небольшой сигнатуре;
47
- - предоставляет React hook для base64-данных.
48
+ - предоставляет React hook для base64-данных;
49
+ - проверяет стандартную Base64-форму и считает размер без выделения decoded buffer через `getBase64DecodedSize`.
48
50
 
49
51
  ### Чего модуль не делает
50
52
 
@@ -75,7 +77,7 @@ import { Meta } from "@storybook/addon-docs/blocks";
75
77
  Импортируйте API только из опубликованного subpath `/binary`:
76
78
 
77
79
  ```ts
78
- import { binaryToBlob, detectMimeType, useBinaryFile } from "@ryuzaki13/react-foundation-lib/binary";
80
+ import { binaryToBlob, detectMimeType, getBase64DecodedSize, useBinaryFile } from "@ryuzaki13/react-foundation-lib/binary";
79
81
  ```
80
82
 
81
83
  Корневой импорт пакета не поддерживается:
@@ -112,6 +114,27 @@ npm install react react-dom
112
114
 
113
115
  В тестовой или серверной среде эти Web APIs могут отсутствовать либо вести себя иначе. Наличие похожих globals в конкретной версии Node.js не превращает browser contract в гарантированный server API.
114
116
 
117
+ `getBase64DecodedSize` использует только строки и числа. Эта функция подходит для client-safe или server request validation и не вызывает browser API, React hooks, `Buffer` или Node.js imports. Существующий barrel `/binary` по-прежнему экспортирует React hook, поэтому его peer-контракт сохраняется.
118
+
119
+ ## `getBase64DecodedSize`
120
+
121
+ ```ts
122
+ function getBase64DecodedSize(value: string): number | null;
123
+
124
+ getBase64DecodedSize("AA=="); // 1 байт
125
+ getBase64DecodedSize("AAE="); // 2 байта
126
+ getBase64DecodedSize("QUJD"); // 3 байта
127
+ getBase64DecodedSize(""); // 0
128
+ getBase64DecodedSize("AA"); // null: отсутствует padding
129
+ getBase64DecodedSize("AAA\n"); // null: перенос строки недопустим
130
+ ```
131
+
132
+ Принимается стандартный алфавит `A–Z`, `a–z`, `0–9`, `+`, `/`, длина должна быть кратна четырём. Один или два символа `=` допустимы только в конце; неверный алфавит, положение или количество padding возвращают `null`. Пустая строка является корректной формой с размером `0`; domain contract отдельно решает, разрешён ли пустой файл.
133
+
134
+ Функция не нормализует whitespace, Base64URL, data URL или отсутствующий padding. Она проверяет форму и вычисляет количество decoded bytes, не декодируя содержимое и не проверяя canonical pad bits, MIME или формат файла. В отличие от browser `binaryToBlob`, это узкий API для проверки строгого transport payload до выделения decoded buffer. Ограничение длины входного HTTP body и лимит размера файла остаются у вызывающей boundary.
135
+
136
+ Время работы — `O(n)` по длине строки, дополнительная память — `O(1)`. Тесты: [`getBase64DecodedSize.test.ts`](./getBase64DecodedSize.test.ts).
137
+
115
138
  ## Минимальные понятия
116
139
 
117
140
  ### Байт
@@ -1307,11 +1330,12 @@ npm run test -- src/binary/binaryToBlob.test.ts
1307
1330
  import { binaryToBlob, detectMimeType, useBinaryFile } from "@ryuzaki13/react-foundation-lib/binary";
1308
1331
  ```
1309
1332
 
1310
- | API | Вход | Результат | Ошибка |
1311
- | ---------------- | ------------------------------- | ----------------- | ---------------------------------------- |
1312
- | `binaryToBlob` | Строка, MIME, base64/raw флаг | Непустой `Blob` | Выбрасывает `Error`/browser decode error |
1313
- | `detectMimeType` | Filename и/или base64/data URL | MIME string | Не выбрасывает; fallback PDF |
1314
- | `useBinaryFile` | Base64/data URL или `undefined` | `{ data, error }` | Логирует и возвращает строку ошибки |
1333
+ | API | Вход | Результат | Ошибка |
1334
+ | ---------------------- | ------------------------------- | ----------------- | ------------------------------------------------ |
1335
+ | `binaryToBlob` | Строка, MIME, base64/raw флаг | Непустой `Blob` | Выбрасывает `Error`/browser decode error |
1336
+ | `getBase64DecodedSize` | Стандартный Base64 payload | Количество байтов | `null` для неверной формы; `0` для пустой строки |
1337
+ | `detectMimeType` | Filename и/или base64/data URL | MIME string | Не выбрасывает; fallback PDF |
1338
+ | `useBinaryFile` | Base64/data URL или `undefined` | `{ data, error }` | Логирует и возвращает строку ошибки |
1315
1339
 
1316
1340
  ### Defaults
1317
1341
 
@@ -0,0 +1,64 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation Lib/API/File System" />
4
+
5
+ # Пути внутри серверного файлового хранилища
6
+
7
+ `@ryuzaki13/react-foundation-lib/file-system` — отдельный Node.js entrypoint для проверки путей перед серверным I/O. Он использует `node:fs/promises` и `node:path`; его нельзя импортировать в browser graph или изоморфный route loader.
8
+
9
+ Модуль выделен из проверок хранилища современного приложения. Он не знает доменную сущность, пользователя, БД, environment variables или способ публикации файла. Browser API чтения выбранного `File` остаётся в отдельном `/file`.
10
+
11
+ ## Public API
12
+
13
+ ```ts
14
+ import {
15
+ isFileSystemNotFoundError,
16
+ resolvePathInsideRoot,
17
+ type PathRealpathMode,
18
+ type ResolvePathInsideRootOptions
19
+ } from "@ryuzaki13/react-foundation-lib/file-system";
20
+
21
+ const options = {
22
+ rootPath: "/srv/private-files",
23
+ pathSegments: ["documents", "file.pdf"],
24
+ realpathMode: "parent"
25
+ } satisfies ResolvePathInsideRootOptions;
26
+
27
+ const filePath = await resolvePathInsideRoot(options);
28
+ ```
29
+
30
+ `resolvePathInsideRoot(options)` возвращает `Promise<string>` с нормализованным абсолютным путём. Относительный `rootPath` разрешается от `process.cwd()`, внешние пробелы удаляются, пустая конфигурация отвергается. `pathSegments` принимает readonly-массив. Сам root считается допустимым target, а выход через `..`, абсолютный путь вне root или соседний каталог с похожим именем отвергается.
31
+
32
+ | `PathRealpathMode` | Назначение и условия |
33
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `target` — по умолчанию | Root и target должны существовать. `realpath` проверяет, что фактический target после разрешения символических ссылок остаётся внутри фактического root. |
35
+ | `parent` | Root и непосредственный родитель target должны существовать. Режим нужен перед созданием нового файла; сам target может отсутствовать. Проверяется фактический путь родителя. |
36
+ | `none` | Только лексическая нормализация и проверка границ. Root и target могут отсутствовать. Символические ссылки не проверяются. |
37
+
38
+ Функция возвращает нормализованный исходный путь, а не заменяет его результатом `realpath`. Символическая ссылка внутри root разрешена, если проверяемый фактический путь остаётся внутри root; доверенный root тоже может быть символической ссылкой.
39
+
40
+ ## Ошибки и граница I/O
41
+
42
+ Пустой root, недоступный root и выход за границы приводят к rejected promise. Ошибка доступа к root сохраняет исходную ошибку в `Error.cause`; ошибки `realpath` target или parent передаются вызывающему коду без замены.
43
+
44
+ `isFileSystemNotFoundError(error: unknown): boolean` распознаёт `ENOENT` и `ENOTDIR`. Ошибки доступа, диска или произвольный текст с названием кода не считаются отсутствующим файлом:
45
+
46
+ ```ts
47
+ try {
48
+ await resolvePathInsideRoot({ rootPath: "/srv/private-files", pathSegments: ["documents", "file.pdf"] });
49
+ } catch (error) {
50
+ if (isFileSystemNotFoundError(error)) {
51
+ // Здесь вызывающий сценарий выбирает состояние отсутствующего файла.
52
+ } else {
53
+ throw error;
54
+ }
55
+ }
56
+ ```
57
+
58
+ Проверка пути не открывает файл, не создаёт каталог и не устанавливает filesystem lock. Между проверкой и I/O путь может измениться. Режим `parent` также не проверяет существующий target symlink. Вызывающий storage adapter выбирает безопасные open flags, контролирует владельца каталогов и выполняет фактическое чтение или запись.
59
+
60
+ Загрузка байтов, checksum, MIME validation, staging, публикация, удаление, транзакции и компенсация принадлежат соответствующим владельцам. Этот модуль предоставляет только проверку границ пути и распознавание отсутствующего пути.
61
+
62
+ ## Проверки
63
+
64
+ Тесты используют реальные временные каталоги и символические ссылки: относительный root, dot-сегменты, абсолютные пути, соседние каталоги, parent/target, недоступный root и системные ошибки. Type-test проверяет публичные сигнатуры и readonly-массив сегментов.
@@ -4,7 +4,7 @@ import { Meta } from "@storybook/addon-docs/blocks";
4
4
 
5
5
  # Notifications
6
6
 
7
- Модуль `@ryuzaki13/react-foundation-lib/notifications` хранит активный toast-стек и полную историю уведомлений, а также предоставляет два способа управления ими:
7
+ Модуль `@ryuzaki13/react-foundation-lib/notifications` хранит активный toast-стек и историю сохраняемых уведомлений, а также предоставляет два способа управления ими:
8
8
 
9
9
  - Zustand-store — для React-интерфейса, который отображает уведомления;
10
10
  - объект `notify` — для императивных вызовов из обработчиков, сервисов и `try/catch`.
@@ -88,7 +88,7 @@ export function NotificationsViewport() {
88
88
  }
89
89
  ```
90
90
 
91
- `items` содержит только активные toast-уведомления и ограничен шестью элементами. Для отдельного центра уведомлений, страницы или содержимого диалога подписывайтесь на `state.history`: эта коллекция не сокращается при TTL, ручном закрытии и переполнении toast-стека.
91
+ `items` содержит только активные toast-уведомления и ограничен шестью элементами. Для отдельного центра уведомлений, страницы или содержимого диалога подписывайтесь на `state.history`: эта коллекция не сокращается при TTL, ручном закрытии и переполнении toast-стека. По умолчанию все уведомления сохраняются; явный `retainInHistory: false` оставляет уведомление только в активном стеке.
92
92
 
93
93
  ### 3. Показывайте уведомления
94
94
 
@@ -123,6 +123,7 @@ type Notification = {
123
123
  ttlMs?: number;
124
124
  dismissible?: boolean;
125
125
  actions?: NotificationAction[];
126
+ retainInHistory?: boolean;
126
127
  };
127
128
  ```
128
129
 
@@ -130,6 +131,8 @@ type Notification = {
130
131
 
131
132
  `actions` содержат функции и потому не сериализуются. Не сохраняйте очередь уведомлений в JSON, localStorage или серверной базе.
132
133
 
134
+ `retainInHistory: false` запрещает хранить уведомление в `history`. Поле необязательно: его отсутствие сохраняет прежнее поведение и прежнюю форму объекта уведомления. `true` явно разрешает сохранение. Политика действует только на историю этого store, а не на сетевой транспорт, внешние журналы или другие хранилища.
135
+
133
136
  ## API store
134
137
 
135
138
  ### `createNotificationsStore()`
@@ -151,7 +154,7 @@ type NotificationsStore = {
151
154
  };
152
155
  ```
153
156
 
154
- `items` — стек видимых toast-уведомлений, ограниченный шестью элементами. `history` — полная история за время жизни store. Новое уведомление помещается в начало обеих коллекций. При добавлении с уже существующим `id` старый элемент заменяется, а новый получает новое `createdAt`.
157
+ `items` — стек видимых toast-уведомлений, ограниченный шестью элементами. `history` — история сохраняемых уведомлений за время жизни store. Новое уведомление помещается в начало стека, а в историю — только если не задан `retainInHistory: false`. При добавлении с уже существующим `id` старый элемент заменяется, а новый получает новое `createdAt` и политику нового `push`; отказ от истории удаляет и прежнюю запись с этим ID.
155
158
 
156
159
  История живёт в памяти и не является журналом аудита: она сбрасывается при пересоздании store или явном `clearHistory()`. Поскольку `actions` уведомлений содержат функции, модуль намеренно не сохраняет историю в `localStorage` и не сериализует её.
157
160
 
@@ -173,6 +176,23 @@ const id = notificationsStore.getState().actions.push({
173
176
 
174
177
  Если `ttlMs` отсутствует, внутренний таймер всё равно запускается на 10 секунд. При этом в объекте уведомления поле остаётся `undefined`.
175
178
 
179
+ ### Эфемерный toast без истории
180
+
181
+ Если текст нужен только во время краткого показа и не должен оставаться в общем центре уведомлений, задайте политику при добавлении:
182
+
183
+ ```ts
184
+ const id = notificationsStore.getState().actions.push({
185
+ type: "info",
186
+ message: "Краткое уведомление",
187
+ retainInHistory: false
188
+ });
189
+
190
+ // Обновление без новой политики сохраняет отказ от истории.
191
+ notificationsStore.getState().actions.update(id, { message: "Обновлённое краткое уведомление" });
192
+ ```
193
+
194
+ Видимость, действия и TTL работают как у обычного toast. Закрытие, истечение TTL и вытеснение из стека не добавляют его текст в историю. Отдельного реестра закрытых эфемерных уведомлений нет: после закрытия новый `push` или отсутствующий `upsert` с тем же ID создаёт новое уведомление с новой политикой.
195
+
176
196
  ### `update(id, patch)`
177
197
 
178
198
  Частично меняет существующий элемент. Возвращает `true`, если элемент найден, иначе `false`.
@@ -187,6 +207,10 @@ const updated = notificationsStore.getState().actions.update(id, {
187
207
 
188
208
  `id` и `createdAt` изменить нельзя. Таймер перестраивается только тогда, когда в patch явно присутствует ключ `ttlMs`. Изменение одного `message` не продлевает исходный срок жизни.
189
209
 
210
+ Если `retainInHistory` отсутствует или равен `undefined`, сохраняется текущая политика. Явный `false` удаляет из истории запись этого ID, не затрагивая остальные уведомления. Явный `true` возвращает активное уведомление в начало истории, если его там нет; существующая запись обновляется на прежнем месте без дубликата.
211
+
212
+ После `clearHistory()` обычное обновление не возвращает текст в историю, даже если у активного уведомления сохранился `retainInHistory: true`. Для возврата нужно снова явно передать `true` в patch. Если активного уведомления уже нет, `update` возвращает `false` и не создаёт запись истории.
213
+
190
214
  ### `upsert(input)`
191
215
 
192
216
  Обновляет элемент с заданным `id`, а если его нет — добавляет новый.
@@ -200,11 +224,11 @@ notificationsStore.getState().actions.upsert({
200
224
  });
201
225
  ```
202
226
 
203
- Важная деталь: при обновлении существующего элемента `upsert` меняет `type`, `title`, `message`, `ttlMs` и `dismissible`, но не заменяет `actions`. Для явной смены действий используйте `update(id, { actions })`.
227
+ Важная деталь: при обновлении существующего элемента `upsert` меняет `type`, `title`, `message`, `ttlMs` и `dismissible`, но не заменяет `actions`. Для явной смены действий используйте `update(id, { actions })`. Поле `retainInHistory` следует правилам `update`: отсутствие или `undefined` сохраняет прежнюю политику, определённое значение меняет её явно. Если toast отсутствует, применяется обычный `push` с переданными параметрами.
204
228
 
205
229
  ### `dismiss(id)`, `clear()` и `clearHistory()`
206
230
 
207
- `dismiss(id)` скрывает один toast и очищает его таймер. `clear()` скрывает весь toast-стек и очищает все таймеры. Обе операции сохраняют записи в `history`.
231
+ `dismiss(id)` скрывает один toast и очищает его таймер. `clear()` скрывает весь toast-стек и очищает все таймеры. Обе операции сохраняют существующие записи в `history` и не добавляют туда уведомления с отказом от истории.
208
232
 
209
233
  `clearHistory()` явно очищает историю, не закрывая активные toast-уведомления. Чтобы полностью сбросить обе коллекции, последовательно вызовите `clear()` и `clearHistory()`.
210
234
 
@@ -239,7 +263,7 @@ notify.warning(message, options);
239
263
  notify.error(message, options);
240
264
  ```
241
265
 
242
- Сокращённые методы автоматически подставляют `type`, но принимают остальные параметры `push`, включая `id`, `title`, `ttlMs`, `dismissible` и `actions`.
266
+ Сокращённые методы автоматически подставляют `type`, но принимают остальные параметры `push`, включая `id`, `title`, `ttlMs`, `dismissible`, `actions` и `retainInHistory`.
243
267
 
244
268
  ### Долгая операция: `notify.progress()`
245
269
 
@@ -322,13 +346,13 @@ function App() {
322
346
 
323
347
  | Экспорт | Назначение |
324
348
  | ---------------------------- | -------------------------------------------------------- |
325
- | `createNotificationsStore()` | Создать независимый Zustand-store toast-стека и истории |
326
- | `bindNotifications(store)` | Привязать store к глобальному фасаду `notify` |
327
- | `notify` | Императивно добавлять, обновлять и закрывать уведомления |
328
- | `Notification` | Полная модель сохранённого элемента |
329
- | `NotificationPushInput` | Параметры добавления элемента |
330
- | `NotificationUpdatePatch` | Допустимые поля частичного обновления |
331
- | `NotificationsState` | Состояние с активными `items` и полной `history` |
332
- | `NotificationsActions` | Действия toast-стека и явная очистка истории |
333
- | `NotificationsStoreApi` | Тип vanilla Zustand-store |
334
- | `NotificationAction` | Кнопка-действие уведомления |
349
+ | `createNotificationsStore()` | Создать независимый Zustand-store toast-стека и истории |
350
+ | `bindNotifications(store)` | Привязать store к глобальному фасаду `notify` |
351
+ | `notify` | Императивно добавлять, обновлять и закрывать уведомления |
352
+ | `Notification` | Полная модель сохранённого элемента |
353
+ | `NotificationPushInput` | Параметры добавления элемента |
354
+ | `NotificationUpdatePatch` | Допустимые поля частичного обновления |
355
+ | `NotificationsState` | Активные `items` и история сохраняемых уведомлений |
356
+ | `NotificationsActions` | Действия toast-стека и явная очистка истории |
357
+ | `NotificationsStoreApi` | Тип vanilla Zustand-store |
358
+ | `NotificationAction` | Кнопка-действие уведомления |