storage.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. // lib/storage.js
  2. // -----------------------------------------------------------------------------
  3. // Central abstraction layer for reading files and directories from the NAS
  4. // share mounted at `NAS_ROOT_PATH` (e.g. `/mnt/niederlassungen`).
  5. //
  6. // All access to the branch/year/month/day/PDF structure should go through
  7. // these functions instead of using `fs` directly in route handlers.
  8. //
  9. // - Read-only: no write/delete operations here.
  10. // - Async only: uses `fs/promises` + async/await to avoid blocking the event loop.
  11. // -----------------------------------------------------------------------------
  12. import fs from "node:fs/promises"; // Promise-based filesystem API
  13. import path from "node:path"; // Safe path utilities (handles separators)
  14. // Root directory of the NAS share, injected via environment variable.
  15. // On the Linux app server, this is typically `/mnt/niederlassungen`.
  16. // Do NOT cache process.env.NAS_ROOT_PATH at module load time.
  17. // Instead, resolve it on demand so tests (and runtime) can change it.
  18. function getRoot() {
  19. const root = process.env.NAS_ROOT_PATH;
  20. if (!root) {
  21. throw new Error("NAS_ROOT_PATH environment variable is not set");
  22. }
  23. return root;
  24. }
  25. // Build an absolute path below the NAS root from a list of segments.
  26. function fullPath(...segments) {
  27. const root = getRoot();
  28. return path.join(root, ...segments.map(String));
  29. }
  30. // Compare strings that represent numbers in a numeric way.
  31. // This ensures "2" comes before "10" (2 < 10), not after.
  32. function sortNumericStrings(a, b) {
  33. const na = parseInt(a, 10);
  34. const nb = parseInt(b, 10);
  35. if (!Number.isNaN(na) && !Number.isNaN(nb)) {
  36. return na - nb;
  37. }
  38. // Fallback to localeCompare if parsing fails
  39. return a.localeCompare(b, "en");
  40. }
  41. // -----------------------------------------------------------------------------
  42. // 1. Branches (NL01, NL02, ...)
  43. // Path pattern: `${ROOT}/NLxx`
  44. // -----------------------------------------------------------------------------
  45. export async function listBranches() {
  46. // Read the root directory of the NAS share.
  47. // `withFileTypes: true` returns `Dirent` objects so we can call `isDirectory()`
  48. // without extra stat() calls, which is more efficient.
  49. const entries = await fs.readdir(fullPath(), { withFileTypes: true });
  50. return (
  51. entries
  52. .filter(
  53. (entry) =>
  54. entry.isDirectory() && // only directories
  55. entry.name !== "@Recently-Snapshot" && // ignore QNAP snapshot folder
  56. /^NL\d+$/i.test(entry.name) // keep only names like "NL01", "NL02", ...
  57. )
  58. .map((entry) => entry.name)
  59. // Sort by numeric branch number: NL1, NL2, ..., NL10
  60. .sort((a, b) =>
  61. sortNumericStrings(a.replace("NL", ""), b.replace("NL", ""))
  62. )
  63. );
  64. }
  65. // -----------------------------------------------------------------------------
  66. // 2. Years (2023, 2024, ...)
  67. // Path pattern: `${ROOT}/${branch}/${year}`
  68. // -----------------------------------------------------------------------------
  69. export async function listYears(branch) {
  70. const dir = fullPath(branch);
  71. const entries = await fs.readdir(dir, { withFileTypes: true });
  72. return entries
  73. .filter(
  74. (entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name) // exactly 4 digits → year folders like "2024"
  75. )
  76. .map((entry) => entry.name)
  77. .sort(sortNumericStrings);
  78. }
  79. // -----------------------------------------------------------------------------
  80. // 3. Months (01–12)
  81. // Path pattern: `${ROOT}/${branch}/${year}/${month}`
  82. // -----------------------------------------------------------------------------
  83. export async function listMonths(branch, year) {
  84. const dir = fullPath(branch, year);
  85. const entries = await fs.readdir(dir, { withFileTypes: true });
  86. return (
  87. entries
  88. .filter(
  89. (entry) => entry.isDirectory() && /^\d{1,2}$/.test(entry.name) // supports "1" or "10", we normalize below
  90. )
  91. // Normalize to two digits so the UI shows "01", "02", ..., "12"
  92. .map((entry) => entry.name.trim().padStart(2, "0"))
  93. .sort(sortNumericStrings)
  94. );
  95. }
  96. // -----------------------------------------------------------------------------
  97. // 4. Days (01–31)
  98. // Path pattern: `${ROOT}/${branch}/${year}/${month}/${day}`
  99. // -----------------------------------------------------------------------------
  100. export async function listDays(branch, year, month) {
  101. const dir = fullPath(branch, year, month);
  102. const entries = await fs.readdir(dir, { withFileTypes: true });
  103. return entries
  104. .filter(
  105. (entry) => entry.isDirectory() && /^\d{1,2}$/.test(entry.name) // supports "1" or "23"
  106. )
  107. .map((entry) => entry.name.trim().padStart(2, "0"))
  108. .sort(sortNumericStrings);
  109. }
  110. // -----------------------------------------------------------------------------
  111. // 5. Files (PDFs) for a given day
  112. // Path pattern: `${ROOT}/${branch}/${year}/${month}/${day}/<file>.pdf`
  113. // -----------------------------------------------------------------------------
  114. export async function listFiles(branch, year, month, day) {
  115. const dir = fullPath(branch, year, month, day);
  116. const entries = await fs.readdir(dir);
  117. return (
  118. entries
  119. // We only care about PDF files at the moment
  120. .filter((name) => name.toLowerCase().endsWith(".pdf"))
  121. .sort((a, b) => a.localeCompare(b, "en"))
  122. .map((name) => ({
  123. // Just the file name, e.g. "Stapel-1_Seiten-1_Zeit-1048.pdf"
  124. name,
  125. // Relative path from the NAS root, used for download URLs etc.
  126. // Example: "NL01/2024/10/23/Stapel-1_Seiten-1_Zeit-1048.pdf"
  127. relativePath: `${branch}/${year}/${month}/${day}/${name}`,
  128. }))
  129. );
  130. }