Compare commits

...
Author SHA1 Message Date
Releases Homarr 5e2b693f6a chore(release): 1.59.1 [skip ci] 2026-04-10 22:39:52 +00:00
homarr-releases[bot] 8df71f7695 chore(release): automatic release v1.59.1 2026-04-10 22:39:07 +00:00
Meier Lukas db6bb870e3 fix(qbittorrent): requests fail (#5473) 2026-04-11 00:38:05 +02:00
Meier Lukas dc44fac42a revert(deps): downgrade undici from v8 to v7 (#5473) 2026-04-11 00:33:22 +02:00
Meier Lukas 26c2fa555b chore: update documentation sitemap 2026-04-10 23:33:03 +02:00
homarr-releases[bot] 3ce3d221cd chore: update bug report template (#5466)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-04-10 21:54:40 +02:00
Releases Homarr 62e9bb0c49 chore(release): 1.59.0 [skip ci] 2026-04-10 19:43:43 +00:00
homarr-releases[bot] b11eaff043 chore(release): automatic release v1.59.0 2026-04-10 19:42:45 +00:00
homarr-renovate[bot] 845f11526e fix(deps): update dependency dotenv to ^17.4.1 (#5462)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-10 19:22:28 +00:00
homarr-renovate[bot] 7982def853 fix(deps): update dependency next to v16.2.3 [security] (#5461)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-10 21:05:50 +02:00
homarr-renovate[bot] 13e13ba352 fix(deps): update dependency react-quill-new to ^3.8.3 (#5463)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-10 19:05:14 +00:00
homarr-renovate[bot] 55b743496b chore(deps): update dependency axios@&gt;&#x3d;1.0.0 &lt;1.8.2 to >=1.15.0 [security] (#5453)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-10 20:57:19 +02:00
homarr-update-contributors[bot] 4a6aecceea chore: update integrations list readme (#5459)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-04-10 18:49:31 +00:00
Meier Lukas 2454cac7be fix(security): remove salt from database (#5412) 2026-04-10 20:48:20 +02:00
Meier Lukas 41a03ef4e0 fix(logs): missing fields from top level error with metadata in output (#5456) 2026-04-10 20:46:59 +02:00
7879b31630 feat: Anchor integration and Anchor Note widget (#5040)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-04-10 20:43:43 +02:00
homarr-update-contributors[bot] 57ea82f088 chore: update integrations list readme (#5457)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-04-10 17:21:07 +00:00
KyleandMeier Lukas 22d599c0cf feat: speedtest tracker integration (#5363)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-04-10 19:04:42 +02:00
homarr-crowdin[bot] c861878058 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-04-10 16:13:35 +00:00
homarr-update-contributors[bot] 9f5ae31222 chore: update contributors (#5455)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-04-10 16:13:04 +00:00
Meier Lukas 6ec058c835 feat(rss-feed): add optional poster (#5452) 2026-04-10 18:11:20 +02:00
Meier Lukas faffb818f9 feat(rss-feed): add periodic live updates (#5449) 2026-04-09 22:45:44 +02:00
Meier Lukas bdf9439e49 ci: disable skip-stability-days for forks (#5451) 2026-04-09 22:45:01 +02:00
homarr-renovate[bot] 75b36bb160 fix(deps): update dependency undici to v8.0.2 (#5446)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-09 16:01:05 +00:00
homarr-renovate[bot] 43587656bb chore(deps): update dependency proxmox-api&gt;undici to v8.0.2 (#5445)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-09 15:59:56 +00:00
homarr-renovate[bot] 8cab4b0333 fix(deps): update tiptap monorepo to v3.22.2 (#5448)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-09 14:14:48 +00:00
homarr-crowdin[bot] 154ad1e01e chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-04-09 01:09:23 +00:00
homarr-renovate[bot] cd2d0ba53f chore(deps): update dependency esbuild@&lt;&#x3d;0.24.2 to >=0.28.0 (#5436)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 18:14:49 +02:00
homarr-renovate[bot] 37d7c8535f chore(deps): update dependency esbuild to ^0.28.0 (#5435)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 18:14:39 +02:00
homarr-renovate[bot] a0e0083b16 fix(deps): update dependency undici to v8.0.1 (#5440)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 12:06:50 +00:00
homarr-renovate[bot] 5317a25cfb chore(deps): update dependency @types/node to ^24.12.2 (#5443)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 12:05:47 +00:00
homarr-renovate[bot] b619ba1d46 fix(deps): update tanstack-query monorepo to ^5.96.2 (#5442)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 11:08:35 +00:00
homarr-renovate[bot] 2baab5402d fix(deps): update tiptap monorepo to v3.22.1 (#5434)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 11:08:05 +00:00
homarr-renovate[bot] 174851bd19 chore(deps): update dependency @types/node to ^24.12.1 (#5441)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 11:05:37 +00:00
homarr-renovate[bot] a7b195d2cb fix(deps): update dependency fast-xml-parser to ^5.5.10 (#5439)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 11:04:39 +00:00
homarr-renovate[bot] 0ab78142b3 chore(deps): update dependency proxmox-api&gt;undici to v8.0.1 (#5431)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-08 09:08:51 +00:00
homarr-renovate[bot] 3f73474d31 fix(deps): update dependency sass to ^1.99.0 (#5437)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-07 23:47:06 +00:00
homarr-renovate[bot]andMeier Lukas f2f5128cbd fix(deps): update dependency undici to v8 (#5432)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-04-07 16:52:42 +02:00
homarr-renovate[bot] 50bf7cdf19 fix(deps): update dependency dotenv to ^17.4.0 (#5427)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-07 14:24:59 +00:00
homarr-renovate[bot] ec15529ca4 chore(deps): update dependency hono@&lt;4.6.5 to >=4.12.10 (#5430)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-07 14:22:54 +00:00
homarr-renovate[bot] db8a3291da chore(deps): update dependency esbuild@&lt;&#x3d;0.24.2 to >=0.27.5 (#5429)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-07 16:18:33 +02:00
homarr-renovate[bot] e2410f143c chore(deps): update dependency esbuild to ^0.27.5 (#5428)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-07 16:18:19 +02:00
homarr-crowdin[bot] cb2862bc79 chore(lang): updated translations from crowdin (#5417)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-04-07 16:12:15 +02:00
homarr-renovate[bot] dbe8d84202 fix(deps): update dependency adm-zip to v0.5.17 (#5422)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-06 21:45:44 +00:00
homarr-renovate[bot] 612038ad07 fix(deps): update dependency next-intl to v4.9.0 (#5426)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-06 20:46:21 +00:00
homarr-renovate[bot] 72ad935ddf fix(deps): update tanstack-query monorepo to ^5.96.1 (#5421)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-06 09:17:17 +00:00
homarr-renovate[bot] 859747037b fix(deps): update nextjs monorepo to v16.2.2 (#5418)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-06 02:45:18 +00:00
homarr-renovate[bot] dfa87939f2 fix(deps): update turbo monorepo to ^2.9.3 (#5419)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-06 02:44:53 +00:00
homarr-renovate[bot] 402d948fe7 fix(deps): update tiptap monorepo to v3.22.0 (#5414)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-05 21:39:56 +00:00
homarr-renovate[bot] d8480c05a6 fix(deps): update turbo monorepo to ^2.9.2 (#5415)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-05 20:44:00 +00:00
homarr-renovate[bot] 6047eb4477 fix(deps): update dependency next-intl to v4.8.4 (#5413)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-05 16:45:33 +00:00
homarr-renovate[bot] 8b1e3babc0 fix(deps): update tanstack-query monorepo to ^5.96.0 (#5410)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-05 11:37:24 +00:00
homarr-renovate[bot] 1e869f6a96 fix(deps): update dependency typescript-eslint to ^8.58.0 (#5409)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-04 21:40:41 +00:00
homarr-renovate[bot] 7607140f94 fix(deps): update turbo monorepo to ^2.9.1 (#5408)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-04 20:41:33 +00:00
homarr-renovate[bot] f037da5831 chore(deps): update dependency rollup@&gt;&#x3d;4.0.0 &lt;4.22.4 to >=4.60.1 (#5402)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-04 11:36:15 +00:00
homarr-renovate[bot] f0ef4729bf fix(deps): update dependency @tabler/icons-react to ^3.41.1 (#5400)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-04 10:42:36 +00:00
homarr-releases[bot] ab1418514d chore: update bug report template (#5406)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-04-04 10:08:48 +00:00
Releases Homarr a5b6c120dd chore(release): 1.58.1 [skip ci] 2026-04-04 09:53:34 +00:00
homarr-releases[bot] 9e7d5ad629 chore(release): automatic release v1.58.1 2026-04-04 09:52:41 +00:00
Meier Lukas a86cdb9fb9 fix: arabic text in english translations 2026-04-04 11:50:19 +02:00
homarr-update-contributors[bot] 270fce6a0e chore: update integrations list readme (#5404)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-04-04 09:46:50 +00:00
homarr-crowdin[bot] 2680255f44 chore(lang): updated translations from crowdin (#5401)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-04-04 11:45:23 +02:00
Meier Lukas ddf87d278e chore: remove temporary ts-expect-error for search-ch documentation link 2026-04-04 11:41:25 +02:00
homarr-releases[bot] 822d2c194b chore: update bug report template (#5399)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-04-03 21:50:53 +02:00
Releases Homarr f5f7f3e433 chore(release): 1.58.0 [skip ci] 2026-04-03 19:38:14 +00:00
homarr-releases[bot] b1b3b79cd9 chore(release): automatic release v1.58.0 2026-04-03 19:37:17 +00:00
homarr-update-contributors[bot] 35d40d0dea chore: update integrations list readme (#5397)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-04-03 18:28:41 +00:00
Meier Lukas abb2dfc92a feat(integrations): add search.ch integration with timetable widget (#5180) 2026-04-03 20:23:15 +02:00
homarr-renovate[bot] 279d830b79 chore(deps): update dependency conventional-changelog-conventionalcommits to ^9.3.1 (#5396)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-03 17:25:35 +00:00
homarr-renovate[bot] 72a93e681f fix(deps): update trpc monorepo to ^11.16.0 (#5392)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-03 16:48:45 +00:00
Meier Lukas c977b84e2d fix: node-ical prevents many trpc queries from loading (#5395) 2026-04-03 18:26:52 +02:00
homarr-update-contributors[bot] e92c407beb chore: update contributors (#5394)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-04-03 13:24:06 +00:00
homarr-crowdin[bot]andMeier Lukas 05af82bc02 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-04-03 01:17:29 +00:00
homarr-crowdin[bot]andMeier Lukas 531bfd6fcf chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-04-02 18:53:29 +00:00
homarr-renovate[bot] fa40b6b647 fix(deps): update turbo monorepo to ^2.8.21 (#5391)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-02 18:50:09 +00:00
homarr-renovate[bot] c363cf8457 chore(deps): update dependency axios@&gt;&#x3d;1.0.0 &lt;1.8.2 to >=1.14.0 (#5389)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-01 19:09:32 +00:00
homarr-renovate[bot] 0a78e3f12a fix(deps): update dependency drizzle-orm to ^0.45.2 (#5388)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-01 19:02:54 +00:00
homarr-crowdin[bot]andMeier Lukas f380b383a3 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-04-01 18:13:33 +02:00
homarr-renovate[bot] b30dbafbbb fix(deps): update tiptap monorepo to v3.21.0 (#5386)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-01 15:13:38 +00:00
homarr-renovate[bot] a9bcc9d04b fix(deps): update dependency @tabler/icons-react to ^3.41.0 (#5384)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-03-31 23:42:53 +00:00
homarr-renovate[bot] 451e68ab57 fix(deps): update trpc monorepo to ^11.15.1 (#5381)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-03-31 20:49:58 +00:00
Meier Lukas a7ed7a4592 ci(renovate): update prebuilt-debian lockfile automatically (#5379) 2026-03-31 22:11:08 +02:00
139 changed files with 11645 additions and 1653 deletions

No files matched your search

+3
View File
@@ -33,6 +33,9 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.59.0
- 1.58.1
- 1.58.0
- 1.57.1
- 1.57.0
- 1.56.1
+12
View File
@@ -11,6 +11,12 @@
matchPackageNames: ["tsx"],
enabled: false,
},
// Reenable once bug with undici v8 is fixed (https://github.com/homarr-labs/homarr/issues/5467)
{
matchPackageNames: ["undici"],
matchNewValue: "8.*",
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
@@ -31,6 +37,12 @@
automerge: false,
},
],
// Updates lockfile for prebuilt-debian after if package.json is updated, otherwise only root lockfile is updated
postUpgradeTasks: {
commands: ["cd deployments/prebuilt-debian && pnpm install --ignore-workspace --lockfile-only"],
fileFilters: ["deployments/prebuilt-debian/pnpm-lock.yaml"],
executionMode: "update",
},
updateInternalDeps: true,
rangeStrategy: "bump",
automerge: false,
@@ -7,7 +7,7 @@ on:
jobs:
skip-stability-days:
if: ${{ !startsWith(github.head_ref, 'renovate/') }}
if: ${{ !startsWith(github.head_ref, 'renovate/') && github.event.pull_request.head.repo.full_name == github.repository }}
name: Skip Stability Days
runs-on: ubuntu-latest
timeout-minutes: 1
+5 -1
View File
@@ -67,4 +67,8 @@ e2e/shared/tmp
apps/nextjs/public/images/background.png
# next-intl
en.d.json.ts
en.d.json.ts
# personal agent config
AGENTS.md
.opencode
+92
View File
@@ -1,3 +1,95 @@
## [1.59.1](https://github.com/homarr-labs/homarr/compare/v1.59.0...v1.59.1) (2026-04-10)
### Bug Fixes
* **qbittorrent:** requests fail ([#5473](https://github.com/homarr-labs/homarr/issues/5473)) ([db6bb87](https://github.com/homarr-labs/homarr/commit/db6bb870e3be14c7895a2882755d3aee09e44fa6))
### Reverts
* **deps:** downgrade undici from v8 to v7 ([#5473](https://github.com/homarr-labs/homarr/issues/5473)) ([dc44fac](https://github.com/homarr-labs/homarr/commit/dc44fac42aab5679427b2921402dfa11e634e1cf))
## [1.59.0](https://github.com/homarr-labs/homarr/compare/v1.58.1...v1.59.0) (2026-04-10)
### Features
* Anchor integration and Anchor Note widget ([#5040](https://github.com/homarr-labs/homarr/issues/5040)) ([7879b31](https://github.com/homarr-labs/homarr/commit/7879b3163074b6af9381b8eb70dced55faddc506))
* **rss-feed:** add optional poster ([#5452](https://github.com/homarr-labs/homarr/issues/5452)) ([6ec058c](https://github.com/homarr-labs/homarr/commit/6ec058c835040dbf72cd2490d49f94be7da066dc))
* **rss-feed:** add periodic live updates ([#5449](https://github.com/homarr-labs/homarr/issues/5449)) ([faffb81](https://github.com/homarr-labs/homarr/commit/faffb818f99900e3abb5fa78cfbb7980692362b5))
* speedtest tracker integration ([#5363](https://github.com/homarr-labs/homarr/issues/5363)) ([22d599c](https://github.com/homarr-labs/homarr/commit/22d599c0cf64b39f3d616ceadc6c2ac32551f99d))
### Bug Fixes
* **deps:** update dependency @tabler/icons-react to ^3.41.1 ([#5400](https://github.com/homarr-labs/homarr/issues/5400)) ([f0ef472](https://github.com/homarr-labs/homarr/commit/f0ef4729bfb7c3672968b77b9b757bc34877c60f))
* **deps:** update dependency adm-zip to v0.5.17 ([#5422](https://github.com/homarr-labs/homarr/issues/5422)) ([dbe8d84](https://github.com/homarr-labs/homarr/commit/dbe8d84202b00687f16784cbedf61d7488efbbff))
* **deps:** update dependency dotenv to ^17.4.0 ([#5427](https://github.com/homarr-labs/homarr/issues/5427)) ([50bf7cd](https://github.com/homarr-labs/homarr/commit/50bf7cdf194c13bcbf95ef8a6a1bc7eb4c9c3865))
* **deps:** update dependency dotenv to ^17.4.1 ([#5462](https://github.com/homarr-labs/homarr/issues/5462)) ([845f115](https://github.com/homarr-labs/homarr/commit/845f11526ea7b3dd6f543fad48a158a4782c52bb))
* **deps:** update dependency fast-xml-parser to ^5.5.10 ([#5439](https://github.com/homarr-labs/homarr/issues/5439)) ([a7b195d](https://github.com/homarr-labs/homarr/commit/a7b195d2cb25fffe81266e7f6fa9499d94cae733))
* **deps:** update dependency next to v16.2.3 [security] ([#5461](https://github.com/homarr-labs/homarr/issues/5461)) ([7982def](https://github.com/homarr-labs/homarr/commit/7982def853fd69c269946dc2d6377186512baaf9))
* **deps:** update dependency next-intl to v4.8.4 ([#5413](https://github.com/homarr-labs/homarr/issues/5413)) ([6047eb4](https://github.com/homarr-labs/homarr/commit/6047eb4477584172f7d8e6e14237c251ac487d81))
* **deps:** update dependency next-intl to v4.9.0 ([#5426](https://github.com/homarr-labs/homarr/issues/5426)) ([612038a](https://github.com/homarr-labs/homarr/commit/612038ad070681c582cb54ac4298bc07700765c9))
* **deps:** update dependency react-quill-new to ^3.8.3 ([#5463](https://github.com/homarr-labs/homarr/issues/5463)) ([13e13ba](https://github.com/homarr-labs/homarr/commit/13e13ba3526436b044ac83d08c346e8e406ca3d4))
* **deps:** update dependency sass to ^1.99.0 ([#5437](https://github.com/homarr-labs/homarr/issues/5437)) ([3f73474](https://github.com/homarr-labs/homarr/commit/3f73474d3125279d94395272365aa67e0a0f69e8))
* **deps:** update dependency typescript-eslint to ^8.58.0 ([#5409](https://github.com/homarr-labs/homarr/issues/5409)) ([1e869f6](https://github.com/homarr-labs/homarr/commit/1e869f6a96d7e34af068b5666e01093a4b20fe97))
* **deps:** update dependency undici to v8 ([#5432](https://github.com/homarr-labs/homarr/issues/5432)) ([f2f5128](https://github.com/homarr-labs/homarr/commit/f2f5128cbd961b6576e195ac8b695f12996c377e))
* **deps:** update dependency undici to v8.0.1 ([#5440](https://github.com/homarr-labs/homarr/issues/5440)) ([a0e0083](https://github.com/homarr-labs/homarr/commit/a0e0083b16ea6ce42a0e3594ad53321210c98141))
* **deps:** update dependency undici to v8.0.2 ([#5446](https://github.com/homarr-labs/homarr/issues/5446)) ([75b36bb](https://github.com/homarr-labs/homarr/commit/75b36bb1609a0ea1a97158f4674ebf8f4c5f205e))
* **deps:** update nextjs monorepo to v16.2.2 ([#5418](https://github.com/homarr-labs/homarr/issues/5418)) ([8597470](https://github.com/homarr-labs/homarr/commit/859747037b4a0caca9e208e0b4ebce638b6c5d4e))
* **deps:** update tanstack-query monorepo to ^5.96.0 ([#5410](https://github.com/homarr-labs/homarr/issues/5410)) ([8b1e3ba](https://github.com/homarr-labs/homarr/commit/8b1e3babc02d8ab6a7db9fd85325a0b92813ce8c))
* **deps:** update tanstack-query monorepo to ^5.96.1 ([#5421](https://github.com/homarr-labs/homarr/issues/5421)) ([72ad935](https://github.com/homarr-labs/homarr/commit/72ad935ddf831c008e53ae3a1c5e924cc82710d1))
* **deps:** update tanstack-query monorepo to ^5.96.2 ([#5442](https://github.com/homarr-labs/homarr/issues/5442)) ([b619ba1](https://github.com/homarr-labs/homarr/commit/b619ba1d46768cc3ed1d3739e0f379e0059bc2d9))
* **deps:** update tiptap monorepo to v3.22.0 ([#5414](https://github.com/homarr-labs/homarr/issues/5414)) ([402d948](https://github.com/homarr-labs/homarr/commit/402d948fe7d36406b7a75a4e4861330c6bcaa78b))
* **deps:** update tiptap monorepo to v3.22.1 ([#5434](https://github.com/homarr-labs/homarr/issues/5434)) ([2baab54](https://github.com/homarr-labs/homarr/commit/2baab5402d79caba0e1b3c53f52fdf0cbeb6875d))
* **deps:** update tiptap monorepo to v3.22.2 ([#5448](https://github.com/homarr-labs/homarr/issues/5448)) ([8cab4b0](https://github.com/homarr-labs/homarr/commit/8cab4b03333050376ae22a14eaaa1c0f045992d3))
* **deps:** update turbo monorepo to ^2.9.1 ([#5408](https://github.com/homarr-labs/homarr/issues/5408)) ([7607140](https://github.com/homarr-labs/homarr/commit/7607140f94f5b19a6b9cbacc7deabd2afc004d24))
* **deps:** update turbo monorepo to ^2.9.2 ([#5415](https://github.com/homarr-labs/homarr/issues/5415)) ([d8480c0](https://github.com/homarr-labs/homarr/commit/d8480c05a63525dc655a73675c2a9eec575a9539))
* **deps:** update turbo monorepo to ^2.9.3 ([#5419](https://github.com/homarr-labs/homarr/issues/5419)) ([dfa8793](https://github.com/homarr-labs/homarr/commit/dfa87939f200719af9f34e0e409895cf6e5a9c13))
* **logs:** missing fields from top level error with metadata in output ([#5456](https://github.com/homarr-labs/homarr/issues/5456)) ([41a03ef](https://github.com/homarr-labs/homarr/commit/41a03ef4e03675c5adf1171be152a767679bc466))
* **security:** remove salt from database ([#5412](https://github.com/homarr-labs/homarr/issues/5412)) ([2454cac](https://github.com/homarr-labs/homarr/commit/2454cac7be14207f822bf3e38f9a7fd261645dc5))
## [1.58.1](https://github.com/homarr-labs/homarr/compare/v1.58.0...v1.58.1) (2026-04-04)
### Bug Fixes
* arabic text in english translations ([a86cdb9](https://github.com/homarr-labs/homarr/commit/a86cdb9fb91a10c8250f849e70c17777c18ada77))
## [1.58.0](https://github.com/homarr-labs/homarr/compare/v1.57.1...v1.58.0) (2026-04-03)
### Features
* **integrations:** add search.ch integration with timetable widget ([#5180](https://github.com/homarr-labs/homarr/issues/5180)) ([abb2dfc](https://github.com/homarr-labs/homarr/commit/abb2dfc92ab51b16e7172374cfee8b9c0c752454))
### Bug Fixes
* **boards:** unable to click on links at settings page ([#5230](https://github.com/homarr-labs/homarr/issues/5230)) ([6a200c3](https://github.com/homarr-labs/homarr/commit/6a200c39afaa1a9a1057643612065a40ad1cdd82))
* **deps:** update dependency @ctrl/deluge to ^7.5.3 ([#5365](https://github.com/homarr-labs/homarr/issues/5365)) ([9c134c5](https://github.com/homarr-labs/homarr/commit/9c134c5ceb742b21b021ef3b49de8cc0d526d460))
* **deps:** update dependency @ctrl/deluge to v8 ([#5367](https://github.com/homarr-labs/homarr/issues/5367)) ([e88e3d1](https://github.com/homarr-labs/homarr/commit/e88e3d17c01dab8836bec11d2ac01195f06cdc2e))
* **deps:** update dependency @immich/sdk to ^2.6.2 ([#5370](https://github.com/homarr-labs/homarr/issues/5370)) ([4a5bafa](https://github.com/homarr-labs/homarr/commit/4a5bafa79092b209e94a56f72362011f416bf115))
* **deps:** update dependency @immich/sdk to ^2.6.3 ([#5380](https://github.com/homarr-labs/homarr/issues/5380)) ([2dd3c90](https://github.com/homarr-labs/homarr/commit/2dd3c906c4215b55e23f4b15ba4b423e23be8376))
* **deps:** update dependency @tabler/icons-react to ^3.41.0 ([#5384](https://github.com/homarr-labs/homarr/issues/5384)) ([a9bcc9d](https://github.com/homarr-labs/homarr/commit/a9bcc9d04bdfcf28ea561cd590ca2a4bd775593c))
* **deps:** update dependency better-sqlite3 to ^12.8.0 ([#5266](https://github.com/homarr-labs/homarr/issues/5266)) ([46db8b6](https://github.com/homarr-labs/homarr/commit/46db8b6ab32406fb4ff7a7a9938692954778a070))
* **deps:** update dependency drizzle-orm to ^0.45.2 ([#5388](https://github.com/homarr-labs/homarr/issues/5388)) ([0a78e3f](https://github.com/homarr-labs/homarr/commit/0a78e3f12aa99509f42d7af647a478c5abaf5c96))
* **deps:** update dependency fast-xml-parser to ^5.5.9 ([#5359](https://github.com/homarr-labs/homarr/issues/5359)) ([189806b](https://github.com/homarr-labs/homarr/commit/189806b5a2b8e67f925cc8def6bc8ddefdc5bfc4))
* **deps:** update dependency fastify to ^5.8.4 ([#5351](https://github.com/homarr-labs/homarr/issues/5351)) ([728f7e4](https://github.com/homarr-labs/homarr/commit/728f7e489aa499bcd7e97c7ec8571e29e7e2cab3))
* **deps:** update dependency isomorphic-dompurify to ^3.7.0 ([#5366](https://github.com/homarr-labs/homarr/issues/5366)) ([c2b54dd](https://github.com/homarr-labs/homarr/commit/c2b54ddfaab93daa4e65c0c38bf5c27e4402a492))
* **deps:** update dependency isomorphic-dompurify to ^3.7.1 ([#5369](https://github.com/homarr-labs/homarr/issues/5369)) ([8d1ff40](https://github.com/homarr-labs/homarr/commit/8d1ff4065d669268f0e6a03f2b68c1ba728a756a))
* **deps:** update dependency jotai to ^2.19.0 ([#5368](https://github.com/homarr-labs/homarr/issues/5368)) ([14d8717](https://github.com/homarr-labs/homarr/commit/14d87178b5a93bdc6fb74793feed95d014814d0b))
* **deps:** update dependency node-ical to ^0.23.1 - abandoned ([#4856](https://github.com/homarr-labs/homarr/issues/4856)) ([b07c545](https://github.com/homarr-labs/homarr/commit/b07c545f17f4dafa62167047baa9eed4b7ee4395))
* **deps:** update dependency typescript-eslint to ^8.57.2 ([#5360](https://github.com/homarr-labs/homarr/issues/5360)) ([cefbf0e](https://github.com/homarr-labs/homarr/commit/cefbf0e2715e9adbc72cdb0b83d84c309e3257d6))
* **deps:** update dependency undici to v7.24.6 ([#5374](https://github.com/homarr-labs/homarr/issues/5374)) ([4f2cc9a](https://github.com/homarr-labs/homarr/commit/4f2cc9af2a42e19624def3bdea60fcb00334c483))
* **deps:** update dependency zod to ^4.3.5 - abandoned ([#4803](https://github.com/homarr-labs/homarr/issues/4803)) ([2b4c83d](https://github.com/homarr-labs/homarr/commit/2b4c83dccac168ffa6f9b6e184ed004659373cf7))
* **deps:** update dependency zod to ^4.3.6 ([#5376](https://github.com/homarr-labs/homarr/issues/5376)) ([a9582f0](https://github.com/homarr-labs/homarr/commit/a9582f04edb0e161d1fab6e64b13d0ce463ef341))
* **deps:** update tanstack-query monorepo to ^5.95.0 ([#5325](https://github.com/homarr-labs/homarr/issues/5325)) ([b11bf36](https://github.com/homarr-labs/homarr/commit/b11bf367d0567e4c8b9068804442ebd200dab01c))
* **deps:** update tanstack-query monorepo to ^5.95.1 ([#5355](https://github.com/homarr-labs/homarr/issues/5355)) ([332b6b4](https://github.com/homarr-labs/homarr/commit/332b6b4f42e7c90570cd4c25a6e6a331a76c660f))
* **deps:** update tanstack-query monorepo to ^5.95.2 ([#5357](https://github.com/homarr-labs/homarr/issues/5357)) ([6820991](https://github.com/homarr-labs/homarr/commit/682099189b689ea17e16af41fd280a72ed68016f))
* **deps:** update tiptap monorepo to v3.20.5 ([#5371](https://github.com/homarr-labs/homarr/issues/5371)) ([6820f8a](https://github.com/homarr-labs/homarr/commit/6820f8afea622587c1ee18b13d13a041ca0572ee))
* **deps:** update tiptap monorepo to v3.21.0 ([#5386](https://github.com/homarr-labs/homarr/issues/5386)) ([b30dbaf](https://github.com/homarr-labs/homarr/commit/b30dbafbbbad468ff37ff11237a64ec29df62345))
* **deps:** update trpc monorepo to ^11.15.0 ([#5354](https://github.com/homarr-labs/homarr/issues/5354)) ([2fb2981](https://github.com/homarr-labs/homarr/commit/2fb2981d2fb79ca2ca6a9bc5c08d91c8fa5710e7))
* **deps:** update trpc monorepo to ^11.15.1 ([#5381](https://github.com/homarr-labs/homarr/issues/5381)) ([451e68a](https://github.com/homarr-labs/homarr/commit/451e68ab57551e23dd7ae772b7483cc56d57ebbb))
* **deps:** update trpc monorepo to ^11.16.0 ([#5392](https://github.com/homarr-labs/homarr/issues/5392)) ([72a93e6](https://github.com/homarr-labs/homarr/commit/72a93e681f3701459176692857220730c279f80b))
* **deps:** update turbo monorepo to ^2.8.21 ([#5391](https://github.com/homarr-labs/homarr/issues/5391)) ([fa40b6b](https://github.com/homarr-labs/homarr/commit/fa40b6b647b884a7f10c268a22330e0e68b50d4d))
* honor trusted certificates in Immich integration requests ([#5275](https://github.com/homarr-labs/homarr/issues/5275)) ([7679553](https://github.com/homarr-labs/homarr/commit/7679553f16ff2945a3eb6cb668ca3a701d13d5ce)), closes [#5242](https://github.com/homarr-labs/homarr/issues/5242)
* node-ical prevents many trpc queries from loading ([#5395](https://github.com/homarr-labs/homarr/issues/5395)) ([c977b84](https://github.com/homarr-labs/homarr/commit/c977b84e2d84d5724219f5ff5f99bc550438e168))
## [1.57.1](https://github.com/homarr-labs/homarr/compare/v1.57.0...v1.57.1) (2026-03-27)
### Bug Fixes
+12 -12
View File
@@ -55,14 +55,14 @@
"@mantine/modals": "^8.3.18",
"@mantine/tiptap": "^8.3.18",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-query-next-experimental": "^5.95.2",
"@trpc/client": "^11.15.0",
"@trpc/next": "^11.15.0",
"@trpc/react-query": "^11.15.0",
"@trpc/server": "^11.15.0",
"@tabler/icons-react": "^3.41.1",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"@tanstack/react-query-next-experimental": "^5.96.2",
"@trpc/client": "^11.16.0",
"@trpc/next": "^11.16.0",
"@trpc/react-query": "^11.16.0",
"@trpc/server": "^11.16.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "^6.0.0",
@@ -70,20 +70,20 @@
"chroma-js": "^3.2.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"dotenv": "^17.3.1",
"dotenv": "^17.4.1",
"flag-icons": "^7.5.0",
"glob": "^13.0.6",
"isomorphic-dompurify": "^3.7.1",
"jotai": "^2.19.0",
"mantine-react-table": "2.0.0-beta.9",
"next": "16.2.1",
"next": "16.2.3",
"postcss-preset-mantine": "^1.18.0",
"prismjs": "^1.30.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-error-boundary": "^6.1.1",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.98.0",
"sass": "^1.99.0",
"superjson": "2.2.6",
"swagger-ui-react": "^5.32.1",
"use-deep-compare-effect": "^1.8.1",
@@ -94,7 +94,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.2",
"@types/node": "^24.12.0",
"@types/node": "^24.12.2",
"@types/prismjs": "^1.26.6",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
+3 -3
View File
@@ -38,7 +38,7 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"dayjs": "^1.11.20",
"dotenv": "^17.3.1",
"dotenv": "^17.4.1",
"fastify": "^5.8.4",
"superjson": "2.2.6",
"undici": "7.24.6"
@@ -47,9 +47,9 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^24.12.0",
"@types/node": "^24.12.2",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.27.4",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"prettier": "^3.8.1",
"tsx": "4.20.4",
+2 -2
View File
@@ -25,7 +25,7 @@
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^17.3.1",
"dotenv": "^17.4.1",
"tsx": "4.20.4",
"ws": "^8.20.0"
},
@@ -34,7 +34,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.1",
"esbuild": "^0.27.4",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
+45 -24
View File
@@ -64,6 +64,13 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/anchor" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg" alt="Anchor" width="90" height="90" />
<br/>
<p align="center">Anchor</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/aria2" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg" alt="Aria2" width="90" height="90" />
<br/>
@@ -97,15 +104,15 @@
<br/>
<p align="center">Deluge</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/docker-hub" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg" alt="Docker Hub" width="90" height="90" />
<br/>
<p align="center">Docker<br/>Hub</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/emby" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg" alt="Emby" width="90" height="90" />
<br/>
@@ -146,15 +153,15 @@
<br/>
<p align="center">Home<br/>Assistant</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
<br/>
<p align="center">iCal</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/immich" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg" alt="Immich" width="90" height="90" />
<br/>
@@ -195,15 +202,15 @@
<br/>
<p align="center">Nextcloud</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/npm" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg" alt="NPM" width="90" height="90" />
<br/>
<p align="center">NPM</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
<br/>
@@ -244,15 +251,15 @@
<br/>
<p align="center">Pi-hole</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
<br/>
<p align="center">Plex</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/prowlarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg" alt="Prowlarr" width="90" height="90" />
<br/>
@@ -293,15 +300,22 @@
<br/>
<p align="center">Readarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
<br/>
<p align="center">SABnzbd</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/search-ch" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png" alt="Search.ch" width="90" height="90" />
<br/>
<p align="center">Search.ch</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/seerr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg" alt="Seerr" width="90" height="90" />
<br/>
@@ -323,13 +337,20 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/speedtest-tracker" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png" alt="Speedtest Tracker" width="90" height="90" />
<br/>
<p align="center">Speedtest<br/>Tracker</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
<br/>
<p align="center">Tdarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/>
@@ -349,8 +370,8 @@
<br/>
<p align="center">TrueNAS</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/unifi-controller" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png" alt="Unifi Controller" width="90" height="90" />
<br/>
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.57.1",
"version": "1.59.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -43,18 +43,18 @@
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.0",
"@testcontainers/redis": "^11.13.0",
"@turbo/gen": "^2.8.20",
"@turbo/gen": "^2.9.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.2",
"@vitest/ui": "^4.1.2",
"conventional-changelog-conventionalcommits": "^9.3.0",
"conventional-changelog-conventionalcommits": "^9.3.1",
"cross-env": "^10.1.0",
"jsdom": "^29.0.1",
"json5": "^2.2.3",
"prettier": "^3.8.1",
"semantic-release": "^25.0.3",
"testcontainers": "^11.13.0",
"turbo": "^2.8.20",
"turbo": "^2.9.3",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.2"
@@ -79,18 +79,18 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.2",
"@babel/runtime@<7.26.10": ">=7.29.2",
"axios@>=1.0.0 <1.8.2": ">=1.13.6",
"axios@>=1.0.0 <1.8.2": ">=1.15.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.5",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.5",
"esbuild@<=0.24.2": ">=0.27.4",
"esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.9",
"hono@<4.6.5": ">=4.12.10",
"linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.7",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.4",
"rollup@>=4.0.0 <4.22.4": ">=4.60.0",
"rollup@>=4.0.0 <4.22.4": ">=4.60.1",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
+6 -6
View File
@@ -41,13 +41,13 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.4.0",
"@tanstack/react-query": "^5.95.2",
"@trpc/client": "^11.15.0",
"@trpc/react-query": "^11.15.0",
"@trpc/server": "^11.15.0",
"@trpc/tanstack-react-query": "^11.15.0",
"@tanstack/react-query": "^5.96.2",
"@trpc/client": "^11.16.0",
"@trpc/react-query": "^11.16.0",
"@trpc/server": "^11.16.0",
"@trpc/tanstack-react-query": "^11.16.0",
"lodash.clonedeep": "^4.5.0",
"next": "16.2.1",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"superjson": "2.2.6",
+2 -5
View File
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import { hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { generateSecureRandomToken } from "@homarr/common/server";
import { db, eq } from "@homarr/db";
@@ -14,7 +14,6 @@ export const apiKeysRouter = createTRPCRouter({
columns: {
id: true,
apiKey: false,
salt: false,
},
with: {
user: {
@@ -29,14 +28,12 @@ export const apiKeysRouter = createTRPCRouter({
});
}),
create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
const salt = await createSaltAsync();
const randomToken = generateSecureRandomToken(64);
const hashedRandomToken = await hashPasswordAsync(randomToken, salt);
const hashedRandomToken = await hashPasswordAsync(randomToken);
const id = createId();
await db.insert(apiKeys).values({
id,
apiKey: hashedRandomToken,
salt,
userId: ctx.session.user.id,
});
return {
@@ -208,6 +208,7 @@ async function setupUrlRestrictionAsync(dbUrls: string[]) {
hideDescription: false,
textLinesClamp: 3,
maximumAmountPosts: 5,
showPosterImage: true,
} satisfies WidgetComponentProps<"rssFeed">["options"]),
}) satisfies InferInsertModel<typeof items>,
),
+6 -13
View File
@@ -3,7 +3,7 @@ import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
import type { QueryResult } from "pg";
import { z } from "zod/v4";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
@@ -11,8 +11,8 @@ import { and, eq, handleTransactionsAsync, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import { credentialsAdminGroup } from "@homarr/definitions";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import {
@@ -92,15 +92,13 @@ export const userRouter = createTRPCRouter({
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
const hashedPassword = await hashPasswordAsync(input.password);
const userId = createId();
const user = {
id: userId,
name: input.username,
password: hashedPassword,
salt,
};
await handleTransactionsAsync(ctx.db, {
@@ -393,7 +391,6 @@ export const userRouter = createTRPCRouter({
columns: {
id: true,
password: true,
salt: true,
provider: true,
},
where: eq(users.id, input.userId),
@@ -423,8 +420,7 @@ export const userRouter = createTRPCRouter({
});
if (isPreviousPasswordRequired) {
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
const isValid = previousPasswordHash === dbUser.password;
const isValid = await comparePasswordsAsync(input.previousPassword, dbUser.password ?? "");
if (!isValid) {
throw new TRPCError({
@@ -434,8 +430,7 @@ export const userRouter = createTRPCRouter({
}
}
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
const hashedPassword = await hashPasswordAsync(input.password);
await ctx.db
.update(users)
.set({
@@ -583,8 +578,7 @@ export const userRouter = createTRPCRouter({
});
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
const salt = await createSaltAsync();
const hashedPassword = await hashPasswordAsync(input.password, salt);
const hashedPassword = await hashPasswordAsync(input.password);
const userId = createId();
await db.insert(users).values({
@@ -592,7 +586,6 @@ const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBase
name: input.username,
email: input.email,
password: hashedPassword,
salt,
});
return userId;
};
@@ -0,0 +1,114 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({
noteId: z.string(),
});
const logger = createLogger({ module: "anchorNotesRouter" });
const isJsonDeltaString = (value: string) => {
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === "object" && parsed !== null && Array.isArray((parsed as { ops?: unknown }).ops);
} catch {
return false;
}
};
const normalizeAnchorContent = (content: string | undefined) => {
if (content === undefined) return undefined;
const trimmed = content.trim();
if (!trimmed) {
return JSON.stringify({ ops: [{ insert: "\n" }] });
}
if (isJsonDeltaString(content)) return content;
const normalizedText = content.endsWith("\n") ? content : `${content}\n`;
return JSON.stringify({ ops: [{ insert: normalizedText }] });
};
export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
const { data } = await handler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return data;
}),
getNote: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return data;
}),
updateNote: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "anchor"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
const normalizedContent = normalizeAnchorContent(input.content);
const isContentNormalized =
input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent;
try {
const updatedNote = await integrationInstance.updateNoteAsync({
noteId: input.noteId,
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
await noteHandler.invalidateAsync();
return updatedNote;
} catch (error) {
if (error instanceof ResponseError && error.statusCode === 400) {
logger.warn("Anchor update note failed validation", {
noteId: input.noteId,
integrationId: ctx.integration.id,
contentLength: input.content?.length ?? 0,
contentIsJsonDelta: input.content ? isJsonDeltaString(input.content) : false,
contentNormalized: isContentNormalized,
});
}
if (error instanceof ResponseError && error.statusCode === 403) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to edit this note",
});
}
if (error instanceof ResponseError && error.statusCode === 404) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Note not found",
});
}
throw error;
}
}),
});
+3
View File
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
export const widgetRouter = createTRPCRouter({
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
@@ -26,5 +27,7 @@ export const widgetRouter = createTRPCRouter({
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
});
+46 -31
View File
@@ -1,3 +1,4 @@
import { observable } from "@trpc/server/observable";
import SuperJSON from "superjson";
import { z } from "zod/v4";
@@ -5,45 +6,59 @@ import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { items, users } from "@homarr/db/schema";
import type { ExtendedFeedEntry } from "@homarr/request-handler/rss-feeds";
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import type { WidgetComponentProps } from "../../../../widgets/src";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const feedsInput = z.object({
urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(),
});
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure
.input(
z.object({
urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(),
}),
)
.query(async ({ ctx, input }) => {
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
? input.urls
: await restrictUrlsAsync(ctx.db, input.urls);
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
? input.urls
: await restrictUrlsAsync(ctx.db, input.urls);
const rssFeeds = await Promise.all(
urls.map(async (url) => {
const innerHandler = rssFeedsRequestHandler.handler({
url,
count: input.maximumAmountPosts,
});
return await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
}),
);
return rssFeeds
.flatMap((rssFeed) => rssFeed.data.entries)
.slice(0, input.maximumAmountPosts)
.sort((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
const rssFeeds = await Promise.all(
urls.map(async (url) => {
const innerHandler = rssFeedsRequestHandler.handler({
url,
count: input.maximumAmountPosts,
});
}),
return await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
}),
);
return rssFeeds
.flatMap((rssFeed) => rssFeed.data.entries)
.slice(0, input.maximumAmountPosts)
.sort((entryA, entryB) => {
return entryA.published && entryB.published
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
: 0;
});
}),
subscribeFeeds: publicProcedure.input(feedsInput).subscription(({ input }) => {
// It is not necessary to check access to all feeds here, as the client will only subscribe to feeds which already exist
return observable<{ url: string; entries: ExtendedFeedEntry[] }>((emit) => {
const unsubscribes = input.urls.map((url) => {
const innerHandler = rssFeedsRequestHandler.handler({ url, count: input.maximumAmountPosts });
return innerHandler.subscribe((data) => {
emit.next({ url, entries: data.entries });
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
export async function canAccessAllFeedsAsync(db: Database, session: Session | null) {
@@ -0,0 +1,55 @@
import { observable } from "@trpc/server/observable";
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeToDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.subscription(({ ctx }) => {
return observable<{
integrationId: string;
dashboard: SpeedtestTrackerDashboardData;
timestamp: Date;
}>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
return innerHandler.subscribe((dashboard) => {
emit.next({
integrationId: integration.id,
dashboard,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});
@@ -0,0 +1,43 @@
import z from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { timetableOptionsSchema } from "@homarr/integrations/types";
import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
} from "@homarr/request-handler/timetable";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const timetableRouter = createTRPCRouter({
getTimetable: publicProcedure
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable")))
.input(timetableOptionsSchema)
.query(async ({ input, ctx }) => {
const innerHandler = timetableGetTimetableRequestHandler.handler(ctx.integration, {
stationId: input.stationId,
limit: input.limit,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
searchStations: publicProcedure
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable")))
.input(
z.object({
query: z.string(),
}),
)
.query(async ({ input, ctx }) => {
const innerHandler = timetableSearchStationsRequestHandler.handler(ctx.integration, {
query: input.query,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
});
+3 -4
View File
@@ -5,7 +5,7 @@ import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
import { hashPasswordAsync } from "../security";
import { comparePasswordsAsync } from "../security";
import { createSessionAsync } from "../server";
const logger = createLogger({ module: "apiKeyAuth" });
@@ -41,7 +41,6 @@ export const getSessionFromApiKeyAsync = async (
columns: {
id: true,
apiKey: true,
salt: true,
},
with: {
user: {
@@ -60,9 +59,9 @@ export const getSessionFromApiKeyAsync = async (
return null;
}
const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt);
const isValid = await comparePasswordsAsync(apiKey, apiKeyFromDb.apiKey);
if (apiKeyFromDb.apiKey !== hashedApiKey) {
if (!isValid) {
logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_MISMATCH" });
return null;
}
@@ -5,7 +5,7 @@ import { createId } from "@homarr/common";
import { apiKeys, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createSaltAsync, hashPasswordAsync } from "../../security";
import { hashPasswordAsync } from "../../security";
import { getSessionFromApiKeyAsync } from "../get-api-key-session";
// Mock the logger to avoid console output during tests
@@ -118,11 +118,9 @@ const setupAsync = async (options?: SetupOptions) => {
});
if (options?.token) {
const salt = await createSaltAsync();
await db.insert(apiKeys).values({
id: defaultApiKeyId,
apiKey: await hashPasswordAsync(options.token, salt),
salt,
apiKey: await hashPasswordAsync(options.token),
userId: defaultUserId,
});
}
+1 -1
View File
@@ -34,7 +34,7 @@
"bcrypt": "^6.0.0",
"cookies": "^0.9.1",
"ldapts": "8.1.7",
"next": "16.2.1",
"next": "16.2.3",
"next-auth": "5.0.0-beta.30",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -1,4 +1,3 @@
import bcrypt from "bcrypt";
import type { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -7,6 +6,8 @@ import { and, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
import type { userSignInSchema } from "@homarr/validation/user";
import { comparePasswordsAsync } from "../../../security";
const logger = createLogger({ module: "basicAuthorization" });
export const authorizeWithBasicCredentialsAsync = async (
@@ -23,7 +24,7 @@ export const authorizeWithBasicCredentialsAsync = async (
}
logger.info("User is trying to log in. Checking password...", { userName: user.name });
const isValidPassword = await bcrypt.compare(credentials.password, user.password);
const isValidPassword = await comparePasswordsAsync(credentials.password, user.password);
if (!isValidPassword) {
logger.warn("Password for user was incorrect", { userName: user.name });
@@ -4,7 +4,7 @@ import { createId } from "@homarr/common";
import { users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createSaltAsync, hashPasswordAsync } from "../../security";
import { hashPasswordAsync } from "../../security";
import { authorizeWithBasicCredentialsAsync } from "../credentials/authorization/basic-authorization";
const defaultUserId = createId();
@@ -13,12 +13,10 @@ describe("authorizeWithBasicCredentials", () => {
test("should authorize user with correct credentials", async () => {
// Arrange
const db = createDb();
const salt = await createSaltAsync();
await db.insert(users).values({
id: defaultUserId,
name: "test",
salt,
password: await hashPasswordAsync("test", salt),
password: await hashPasswordAsync("test"),
});
// Act
@@ -34,12 +32,10 @@ describe("authorizeWithBasicCredentials", () => {
test("should not authorize user with incorrect credentials", async () => {
// Arrange
const db = createDb();
const salt = await createSaltAsync();
await db.insert(users).values({
id: defaultUserId,
name: "test",
salt,
password: await hashPasswordAsync("test", salt),
password: await hashPasswordAsync("test"),
});
// Act
@@ -55,12 +51,10 @@ describe("authorizeWithBasicCredentials", () => {
test("should not authorize user with incorrect username", async () => {
// Arrange
const db = createDb();
const salt = await createSaltAsync();
await db.insert(users).values({
id: defaultUserId,
name: "test",
salt,
password: await hashPasswordAsync("test", salt),
password: await hashPasswordAsync("test"),
});
// Act
+10 -4
View File
@@ -1,9 +1,15 @@
import bcrypt from "bcrypt";
export const createSaltAsync = async () => {
return bcrypt.genSalt(10);
export const hashPasswordAsync = async (password: string) => {
return bcrypt.hash(password, 10);
};
export const hashPasswordAsync = async (password: string, salt: string) => {
return bcrypt.hash(password, salt);
/**
* Compares a plaintext password with a hashed password and returns whether they match.
* @param password plaintext password
* @param hash hashed password
* @returns wheter it is the same
*/
export const comparePasswordsAsync = async (password: string, hash: string) => {
return await bcrypt.compare(password, hash);
};
+32 -23
View File
@@ -1,25 +1,11 @@
import { describe, expect, it } from "vitest";
import { createSaltAsync, hashPasswordAsync } from "../security";
describe("createSalt should return a salt", () => {
it("should return a salt", async () => {
const result = await createSaltAsync();
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(25);
});
it("should return a different salt each time", async () => {
const result1 = await createSaltAsync();
const result2 = await createSaltAsync();
expect(result1).not.toEqual(result2);
});
});
import { comparePasswordsAsync, hashPasswordAsync } from "../security";
describe("hashPassword should return a hash", () => {
it("should return a hash", async () => {
const password = "password";
const salt = await createSaltAsync();
const result = await hashPasswordAsync(password, salt);
const result = await hashPasswordAsync(password);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(55);
expect(result).not.toEqual(password);
@@ -27,21 +13,44 @@ describe("hashPassword should return a hash", () => {
it("should return a different hash each time", async () => {
const password = "password";
const password2 = "another password";
const salt = await createSaltAsync();
const result1 = await hashPasswordAsync(password, salt);
const result2 = await hashPasswordAsync(password2, salt);
const result1 = await hashPasswordAsync(password);
const result2 = await hashPasswordAsync(password2);
expect(result1).not.toEqual(result2);
});
it("should return a different hash for the same password with different salts", async () => {
const password = "password";
const salt1 = await createSaltAsync();
const salt2 = await createSaltAsync();
const result1 = await hashPasswordAsync(password, salt1);
const result2 = await hashPasswordAsync(password, salt2);
const result1 = await hashPasswordAsync(password);
const result2 = await hashPasswordAsync(password);
expect(result1).not.toEqual(result2);
});
});
describe("comparePasswordsAsync should compare passwords correctly", () => {
it("should return true for matching passwords", async () => {
// Arrange
const password = "password";
const hash = await hashPasswordAsync(password);
// Act
const result = await comparePasswordsAsync(password, hash);
// Assert
expect(result).toBe(true);
});
it("should return false for non-matching passwords", async () => {
// Arrange
const password = "password";
const wrongPassword = "wrongpassword";
const hash = await hashPasswordAsync(password);
// Act
const result = await comparePasswordsAsync(wrongPassword, hash);
// Assert
expect(result).toBe(false);
});
});
+2 -2
View File
@@ -30,13 +30,13 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"dotenv": "^17.3.1"
"dotenv": "^17.4.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "^0.27.4",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"typescript": "^5.9.3"
}
+2 -4
View File
@@ -1,6 +1,6 @@
import { command, string } from "@drizzle-team/brocli";
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import { hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { generateSecureRandomToken } from "@homarr/common/server";
import { and, count, db, eq } from "@homarr/db";
@@ -67,9 +67,8 @@ export const recreateAdmin = command({
permission: "admin",
});
const salt = await createSaltAsync();
const password = generateSecureRandomToken(24);
const hashedPassword = await hashPasswordAsync(password, salt);
const hashedPassword = await hashPasswordAsync(password);
const userId = createId();
await db.insert(users).values({
@@ -77,7 +76,6 @@ export const recreateAdmin = command({
name: result.data,
provider: "credentials",
password: hashedPassword,
salt,
});
await db.insert(groupMembers).values({
+2 -2
View File
@@ -22,7 +22,7 @@ export const resetPassword = command({
where: and(eq(users.name, options.username), eq(users.provider, "credentials")),
});
if (!user?.salt) {
if (!user?.password) {
console.error(`User ${options.username} not found`);
return;
}
@@ -33,7 +33,7 @@ export const resetPassword = command({
await db
.update(users)
.set({
password: await hashPasswordAsync(newPassword, user.salt),
password: await hashPasswordAsync(newPassword),
})
.where(eq(users.id, user.id));
@@ -29,7 +29,7 @@ export const usersUpdatePassword = command({
where: options.id ? eq(users.id, options.id) : eq(users.name, options.username!),
});
if (!user?.salt) {
if (!user?.password) {
console.error("User not found or has no credentials record");
return;
}
@@ -37,7 +37,7 @@ export const usersUpdatePassword = command({
await db
.update(users)
.set({
password: await hashPasswordAsync(options.password, user.salt),
password: await hashPasswordAsync(options.password),
})
.where(eq(users.id, user.id));
+1 -1
View File
@@ -30,7 +30,7 @@
"@paralleldrive/cuid2": "^3.3.0",
"cookie": "1.1.1",
"dayjs": "^1.11.20",
"next": "16.2.1",
"next": "16.2.3",
"octokit": "^5.0.5",
"react": "19.2.4",
"react-dom": "19.2.4",
+1 -1
View File
@@ -40,7 +40,7 @@
"@t3-oss/env-nextjs": "^0.13.11",
"better-sqlite3": "^12.8.0",
"dns-caching": "^0.2.9",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "^0.45.2",
"ioredis": "5.10.1",
"mysql2": "3.20.0",
"pg": "^8.20.0",
@@ -1,7 +1,10 @@
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
export const formatMetadata = (metadata: Record<string, unknown> | Error, ignoreKeys?: string[]) => {
const metadataObject = metadata instanceof ErrorWithMetadata ? metadata.metadata : metadata;
const metadataObject =
metadata instanceof ErrorWithMetadata || ("metadata" in metadata && typeof metadata.metadata === "object")
? (metadata.metadata as Record<string, unknown>)
: metadata;
const filteredMetadata = Object.keys(metadataObject)
.filter((key) => !ignoreKeys?.includes(key))
+4 -4
View File
@@ -28,10 +28,10 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@tanstack/react-query": "^5.95.2",
"@trpc/client": "^11.15.0",
"@trpc/server": "^11.15.0",
"@trpc/tanstack-react-query": "^11.15.0",
"@tanstack/react-query": "^5.96.2",
"@trpc/client": "^11.16.0",
"@trpc/server": "^11.16.0",
"@trpc/tanstack-react-query": "^11.16.0",
"node-cron": "^4.2.1",
"react": "19.2.4",
"zod": "^4.3.6"
+2
View File
@@ -18,6 +18,7 @@ import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { networkControllerJob } from "./jobs/integrations/network-controller";
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
@@ -51,6 +52,7 @@ export const jobGroup = createCronJobGroup({
firewallInterfaces: firewallInterfacesJob,
refreshNotifications: refreshNotificationsJob,
weather: weatherJob,
timetable: timetableJob,
tracearr: tracearrJob,
});
@@ -0,0 +1,20 @@
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { timetableGetTimetableRequestHandler } from "@homarr/request-handler/timetable";
import { createCronJob } from "../../lib";
export const timetableJob = createCronJob("timetable", EVERY_5_MINUTES).withCallback(
createRequestIntegrationJobHandler(timetableGetTimetableRequestHandler.handler, {
widgetKinds: ["timetable"],
getInput: {
timetable: (options) =>
options.station
? {
stationId: options.station.value,
limit: 10,
}
: [],
},
}),
);
+1 -1
View File
@@ -5,9 +5,9 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema";
// This import is done that way to avoid circular dependencies.
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
// This import is done that way to avoid circular dependencies.
import type { WidgetComponentProps } from "../../../widgets";
import { createCronJob } from "../lib";
@@ -0,0 +1,2 @@
ALTER TABLE `apiKey` DROP COLUMN `salt`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `salt`;
File diff suppressed because it is too large. Load diff
@@ -260,6 +260,13 @@
"when": 1760968518445,
"tag": "0036_add_app_reference_to_integration",
"breakpoints": true
},
{
"idx": 37,
"version": "5",
"when": 1775399941430,
"tag": "0037_remove_salt",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "apiKey" DROP COLUMN "salt";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "salt";
File diff suppressed because it is too large. Load diff
@@ -22,6 +22,13 @@
"when": 1760968530084,
"tag": "0002_add_app_reference_to_integration",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1775399963041,
"tag": "0003_remove_salt",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE `apiKey` DROP COLUMN `salt`;--> statement-breakpoint
ALTER TABLE `user` DROP COLUMN `salt`;
File diff suppressed because it is too large. Load diff
@@ -246,6 +246,13 @@
"when": 1760968503571,
"tag": "0034_add_app_reference_to_integration",
"breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1775399918831,
"tag": "0035_remove_salt",
"breakpoints": true
}
]
}
+3 -3
View File
@@ -52,9 +52,9 @@
"@testcontainers/mysql": "^11.13.0",
"@testcontainers/postgresql": "^11.13.0",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"dotenv": "^17.4.1",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.1",
"drizzle-orm": "^0.45.2",
"drizzle-zod": "^0.8.3",
"mysql2": "3.20.0",
"pg": "^8.20.0",
@@ -67,7 +67,7 @@
"@types/better-sqlite3": "7.6.13",
"@types/pg": "^8.20.0",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.27.4",
"esbuild": "^0.28.0",
"eslint": "^9.39.4",
"prettier": "^3.8.1",
"tsx": "4.20.4",
-2
View File
@@ -51,7 +51,6 @@ export * from "@homarr/core/infrastructure/certificates/hostnames/db/mysql";
export const apiKeys = mysqlTable("apiKey", {
id: varchar({ length: 64 }).notNull().primaryKey(),
apiKey: text().notNull(),
salt: text().notNull(),
userId: varchar({ length: 64 })
.notNull()
.references((): AnyMySqlColumn => users.id, {
@@ -66,7 +65,6 @@ export const users = mysqlTable("user", {
emailVerified: timestamp(),
image: text(),
password: text(),
salt: text(),
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
-2
View File
@@ -50,7 +50,6 @@ export * from "@homarr/core/infrastructure/certificates/hostnames/db/postgresql"
export const apiKeys = pgTable("apiKey", {
id: varchar({ length: 64 }).notNull().primaryKey(),
apiKey: text().notNull(),
salt: text().notNull(),
userId: varchar({ length: 64 })
.notNull()
.references((): AnyPgColumn => users.id, {
@@ -65,7 +64,6 @@ export const users = pgTable("user", {
emailVerified: timestamp(),
image: text(),
password: text(),
salt: text(),
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: varchar({ length: 64 }).references((): AnyPgColumn => boards.id, {
onDelete: "set null",
-2
View File
@@ -33,7 +33,6 @@ export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite";
export const apiKeys = sqliteTable("apiKey", {
id: text().notNull().primaryKey(),
apiKey: text().notNull(),
salt: text().notNull(),
userId: text()
.notNull()
.references((): AnySQLiteColumn => users.id, {
@@ -48,7 +47,6 @@ export const users = sqliteTable("user", {
emailVerified: int({ mode: "timestamp_ms" }),
image: text(),
password: text(),
salt: text(),
provider: text().$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
+1 -1
View File
@@ -24,7 +24,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"fast-xml-parser": "^5.5.9",
"fast-xml-parser": "^5.5.10",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -153,6 +153,7 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/synology"
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/adguard-home"
| "/docs/integrations/anchor"
| "/docs/integrations/aria2"
| "/docs/integrations/codeberg"
| "/docs/integrations/dash-dot"
@@ -188,9 +189,11 @@ export type HomarrDocumentationPath =
| "/docs/integrations/radarr"
| "/docs/integrations/readarr"
| "/docs/integrations/sabnzbd"
| "/docs/integrations/search-ch"
| "/docs/integrations/seerr"
| "/docs/integrations/slskd"
| "/docs/integrations/sonarr"
| "/docs/integrations/speedtest-tracker"
| "/docs/integrations/tdarr"
| "/docs/integrations/tracearr"
| "/docs/integrations/transmission"
@@ -207,6 +210,7 @@ export type HomarrDocumentationPath =
| "/docs/management/settings"
| "/docs/management/tasks"
| "/docs/management/users"
| "/docs/widgets/anchor-note"
| "/docs/widgets/app"
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar"
@@ -235,8 +239,10 @@ export type HomarrDocumentationPath =
| "/docs/widgets/rss-feed"
| "/docs/widgets/smart-home-entity-state"
| "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price"
| "/docs/widgets/system-resources"
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/video"
| "/docs/widgets/weather"
+25
View File
@@ -312,6 +312,13 @@ export const integrationDefs = {
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/ical"),
},
anchor: {
name: "Anchor",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
category: ["notes"],
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
},
truenas: {
name: "TrueNAS",
secretKinds: [["username", "password"]],
@@ -334,6 +341,14 @@ export const integrationDefs = {
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
},
searchCh: {
name: "Search.ch",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png",
defaultUrl: "https://search.ch",
category: ["timetable"],
documentationUrl: createDocumentationLink("/docs/integrations/search-ch"),
},
immich: {
name: "Immich",
secretKinds: [["apiKey"]],
@@ -348,6 +363,13 @@ export const integrationDefs = {
category: ["mediaMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
},
speedtestTracker: {
name: "Speedtest Tracker",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -434,8 +456,11 @@ export const integrationCategories = [
"releasesProvider",
"notifications",
"firewall",
"timetable",
"photoService",
"notes",
"mediaMonitoring",
"speedtest",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
+3
View File
@@ -5,6 +5,7 @@ export const widgetKinds = [
"iframe",
"video",
"notebook",
"anchorNote",
"dnsHoleSummary",
"dnsHoleControls",
"smartHome-entityState",
@@ -31,8 +32,10 @@ export const widgetKinds = [
"systemResources",
"coolify",
"systemDisks",
"timetable",
"immich-serverStats",
"immich-albumCarousel",
"tracearr",
"speedtestTracker",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+1 -1
View File
@@ -43,9 +43,9 @@
"@octokit/auth-app": "^8.2.0",
"ical.js": "^2.2.1",
"maria2": "^0.4.1",
"node-ical": "^0.23.1",
"octokit": "^5.0.5",
"proxmox-api": "1.1.1",
"timezones-ical-library": "^2.1.3",
"tsdav": "^2.1.8",
"undici": "7.24.6",
"xml2js": "^0.6.2",
@@ -0,0 +1,118 @@
import type { fetch as undiciFetch } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import { anchorNoteSchema, anchorNoteSummaryListSchema } from "./anchor-types";
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary, AnchorNoteUpdateInput } from "./anchor-types";
const logger = createLogger({ module: "anchorIntegration" });
export class AnchorIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/notes", { limit: 1 }), {
headers: {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
anchorNoteSummaryListSchema.parse(await response.json());
return { success: true };
}
public async listNotesAsync(input: AnchorNotesListInput = {}): Promise<AnchorNoteSummary[]> {
const queryParams = buildListQuery(input);
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, "/api/notes", {
queryParams,
});
return anchorNoteSummaryListSchema.parse(response);
}
public async getNoteAsync(noteId: string): Promise<AnchorNote> {
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`);
return anchorNoteSchema.parse(response);
}
public async updateNoteAsync(input: AnchorNoteUpdateInput): Promise<AnchorNote> {
const { noteId, ...updateData } = input;
const response = await this.requestAsync(fetchWithTrustedCertificatesAsync, `/api/notes/${noteId}`, {
method: "PATCH",
body: updateData,
});
if (response === null) {
return this.getNoteAsync(noteId);
}
return anchorNoteSchema.parse(response);
}
private async requestAsync(
fetchAsync: typeof undiciFetch,
path: `/${string}`,
options?: {
queryParams?: Record<string, string>;
method?: string;
body?: unknown;
},
): Promise<unknown> {
const url = this.url(path, options?.queryParams);
const headers: Record<string, string> = {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
if (options?.body !== undefined) {
headers["Content-Type"] = "application/json";
}
const response = await fetchAsync(url, {
method: options?.method ?? "GET",
headers,
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
let errorBody: string | null = null;
try {
errorBody = await response.text();
} catch {
errorBody = null;
}
logger.warn("Anchor request failed", {
statusCode: response.status,
url,
errorBody: errorBody ? errorBody.slice(0, 200) : null,
});
throw new ResponseError(response);
}
if (response.status === 204) {
return null;
}
return await response.json();
}
}
const buildListQuery = (input: AnchorNotesListInput) => {
const queryParams: Record<string, string> = {};
if (input.search?.trim()) {
queryParams.search = input.search.trim();
}
if (input.tagId?.trim()) {
queryParams.tagId = input.tagId.trim();
}
if (typeof input.limit === "number") {
queryParams.limit = input.limit.toString();
}
return Object.keys(queryParams).length > 0 ? queryParams : undefined;
};
@@ -0,0 +1,43 @@
import { z } from "zod/v4";
export const anchorNotesListInputSchema = z.object({
search: z.string().optional(),
tagId: z.string().optional(),
limit: z.number().min(1).max(200).optional(),
});
export type AnchorNotesListInput = z.infer<typeof anchorNotesListInputSchema>;
export const anchorNotePermissionSchema = z.enum(["owner", "viewer", "editor"]);
export type AnchorNotePermission = z.infer<typeof anchorNotePermissionSchema>;
export const anchorNoteSummarySchema = z.object({
id: z.string(),
title: z.string(),
updatedAt: z.string(),
isPinned: z.boolean(),
tagIds: z.array(z.string()),
permission: anchorNotePermissionSchema,
});
export type AnchorNoteSummary = z.infer<typeof anchorNoteSummarySchema>;
export const anchorNoteSchema = anchorNoteSummarySchema.extend({
content: z.string().nullable().optional(),
createdAt: z.string(),
isArchived: z.boolean(),
background: z.string().nullable().optional(),
userId: z.string(),
});
export type AnchorNote = z.infer<typeof anchorNoteSchema>;
export const anchorNoteSummaryListSchema = z.array(anchorNoteSummarySchema);
export const anchorNoteUpdateInputSchema = z
.object({
noteId: z.string(),
title: z.string().optional(),
content: z.string().optional(),
})
.refine((value) => value.title !== undefined || value.content !== undefined, {
message: "At least one field to update must be provided",
});
export type AnchorNoteUpdateInput = z.infer<typeof anchorNoteUpdateInputSchema>;
@@ -1,6 +1,7 @@
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
@@ -40,7 +41,9 @@ import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { QuayIntegration } from "../quay/quay-integration";
import { SearchChIntegration } from "../search-ch/search-ch-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
@@ -70,6 +73,7 @@ type IntegrationInstance = new (integration: IntegrationInput) => Integration;
// factories are an array, to differentiate in js between class constructors and functions
export const integrationCreators = {
anchor: AnchorIntegration,
piHole: [createPiHoleIntegrationAsync],
adGuardHome: AdGuardHomeIntegration,
homeAssistant: HomeAssistantIntegration,
@@ -114,7 +118,9 @@ export const integrationCreators = {
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
glances: GlancesIntegration,
searchCh: SearchChIntegration,
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
+12
View File
@@ -1,5 +1,6 @@
// General integrations
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
@@ -31,6 +32,7 @@ export { ICalIntegration } from "./ical/ical-integration";
export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -57,9 +59,19 @@ export type {
export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
export type { Notification } from "./interfaces/notifications/notification-types";
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
export type {
AnchorNote,
AnchorNotePermission,
AnchorNoteUpdateInput,
AnchorNotesListInput,
AnchorNoteSummary,
} from "./anchor/anchor-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
@@ -0,0 +1,6 @@
import type { Station, Timetable, TimetableOptions } from "./timetable-types";
export interface TimetableIntegration {
searchStationsAsync(query: string): Promise<Station[]>;
getTimetableAsync(options: TimetableOptions): Promise<Timetable>;
}
@@ -0,0 +1,33 @@
import z from "zod";
export interface Station {
id: string;
name: string;
}
export const timetableOptionsSchema = z.object({
stationId: z.string(),
limit: z.number().default(10),
});
export type TimetableOptions = z.infer<typeof timetableOptionsSchema>;
export interface TimetableEntry {
timestamp: Date;
delay: number;
line: {
name: string;
color: string | null;
} | null;
location: string;
platform: {
name: string;
hasChanged: boolean;
} | null;
}
export interface Timetable {
stationId: string;
timestamp: Date;
entries: TimetableEntry[];
}
@@ -2,8 +2,9 @@ import type { Agent } from "https";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import ICAL from "ical.js";
import type { RequestInit as NodeFetchRequestInit } from "node-fetch";
import * as ical from "node-ical";
import { tzlib_get_ical_block, tzlib_get_timezones } from "timezones-ical-library";
import { DAVClient } from "tsdav";
import { z } from "zod";
@@ -21,6 +22,20 @@ import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
import type { Notification } from "../interfaces/notifications/notification-types";
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
// Register all existing timezones
if (ICAL.TimezoneService.count === 0) {
const timezones = tzlib_get_timezones() as string[];
for (const tzid of timezones) {
ICAL.TimezoneService.register(
new ICAL.Timezone({
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-non-null-assertion
component: new ICAL.Component(ICAL.parse((tzlib_get_ical_block(tzid) as string[])[0]!)),
tzid,
}),
);
}
}
const notificationSchema = z.object({
ocs: z.object({
data: z.array(
@@ -68,39 +83,64 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
return calendarEvents
.map((event) => {
// @ts-expect-error the typescript definitions for this package are wrong
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
const icalData = ical.default.parseICS(event.data) as ical.CalendarResponse;
const veventObject = Object.values(icalData).find((data) => data.type === "VEVENT");
if (!veventObject) {
throw new Error(`Invalid event data object: ${JSON.stringify(event.data)}. Unable to process the calendar.`);
if (typeof event.data !== "string") {
return [];
}
const comp = ICAL.Component.fromString(event.data);
const veventComponent = comp.getFirstSubcomponent("vevent");
if (!veventComponent) {
return [];
}
const veventObject = new ICAL.Event(veventComponent);
logger.debug(`Converting VEVENT event to ${event.etag} from Nextcloud: ${JSON.stringify(veventObject)}`);
const eventUrlWithoutHost = new URL(event.url).pathname;
const eventSlug = Buffer.from(eventUrlWithoutHost).toString("base64url");
const startDates = veventObject.rrule ? veventObject.rrule.between(start, end) : [veventObject.start];
const durationMs = veventObject.duration.toSeconds() * 1000;
const durationMs = veventObject.end.getTime() - veventObject.start.getTime();
// Handle recurring events
let startDates: Date[];
if (veventObject.isRecurring()) {
const iterator = veventObject.iterator();
startDates = [];
// Iterate through occurrences within the time range
let next: ICAL.Time | undefined;
// next actually returns undefined when there are no more occurrences
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while ((next = iterator.next())) {
const nextStartDate = next.convertToZone(ICAL.Timezone.utcTimezone).toJSDate();
if (nextStartDate > end) break;
const nextEndDate = new Date(nextStartDate.getTime() + durationMs);
if (nextEndDate < start) continue;
startDates.push(nextStartDate);
}
} else {
startDates = [veventObject.startDate.convertToZone(ICAL.Timezone.utcTimezone).toJSDate()];
}
return startDates.map((startDate) => {
const utcStartDate = dayjs(startDate).tz("UTC").toDate();
const endDate = new Date(utcStartDate.getTime() + durationMs);
const dateInMillis = utcStartDate.valueOf();
const endDate = new Date(startDate.getTime() + durationMs);
const dateInMillis = startDate.valueOf();
// Get color property from the component
const colorProperty = veventComponent.getFirstProperty("color");
const color = colorProperty?.getFirstValue() as string | undefined;
return {
title: veventObject.summary,
subTitle: null,
description: veventObject.description,
startDate: utcStartDate,
description: veventObject.description || null,
startDate,
endDate,
image: null,
location: veventObject.location || null,
indicatorColor:
"color" in veventObject && typeof veventObject.color === "string" ? veventObject.color : "#ff8600",
indicatorColor: color ?? "#ff8600",
links: [
{
href: this.externalUrl(
@@ -0,0 +1,141 @@
import z from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { TimetableIntegration } from "../interfaces/timetable/timetable-integration";
import type { Station, Timetable, TimetableOptions } from "../interfaces/timetable/timetable-types";
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
export class SearchChIntegration extends Integration implements TimetableIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/timetable/api/completion.json", { term: "Zürich" }));
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
/**
* Searches for stations matching the given query.
* @param query The search query for station names.
* @returns A list of stations matching the search query.
* @docs https://search.ch/fahrplan/api/help
*/
public async searchStationsAsync(query: string): Promise<Station[]> {
const response = await fetchWithTrustedCertificatesAsync(
this.url("/timetable/api/completion.json", {
term: query,
show_ids: 1,
nofavorites: 1,
}),
);
if (!response.ok) {
throw new ResponseError(response);
}
const data = await searchSchema.parseAsync(await response.json());
return (
data
// Remove completions like addresses, we only want stations.
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
.filter((item) => item !== null)
);
}
/**
* Gets the departure timetable for a given station.
* @param options Timetable options including station ID and limit.
* @returns timetable for the given station.
* @docs https://search.ch/fahrplan/api/help
*/
public async getTimetableAsync(options: TimetableOptions): Promise<Timetable> {
const now = new Date();
// The following format is used [01.01.2000, 23:59:00]. The API allows usage of DD.MM.YYYY format and HH:mm format
const [date, time] = now.toLocaleString("en-CH", { timeZone: "Europe/Zurich" }).split(", ") as [string, string];
const response = await fetchWithTrustedCertificatesAsync(
this.url("/timetable/api/stationboard.json", {
stop: options.stationId,
limit: options.limit,
show_delays: 1,
show_tracks: 1,
date,
time: time.substring(0, 5), // Only keep the HH:mm part of the time, as that's what the API expects
}),
);
if (!response.ok) {
throw new ResponseError(response);
}
const data = await timetableSchema.parseAsync(await response.json());
return {
stationId: options.stationId,
timestamp: now,
entries: data.connections.map((connection) => {
// Color is in format A35~F10~ for foreground and background color. Some only have ~~ and therefore no color
const color = connection.color.split("~")[0];
return {
timestamp: connection.time,
line: connection.line
? {
name: connection.line,
color: color && color.length >= 1 ? `#${color}` : null,
}
: null,
location: connection.terminal.name,
delay: connection.dep_delay,
platform: connection.track
? {
// If the track name contains "!", it indicates a change in the track
// so we remove the "!" from the name and set hasChanged to true
name: connection.track.replace("!", ""),
hasChanged: connection.track.includes("!"),
}
: null,
};
}),
};
}
}
const timetableSchema = z.object({
connections: z.array(
z.object({
time: z.string().transform((str) => new Date(str)),
line: z.string().optional(),
color: z.string(),
terminal: z.object({
name: z.string(),
}),
track: z.string().optional(),
dep_delay: z
.string()
.optional()
.transform((str) => (str ? parseInt(str) : 0)),
}),
),
});
const searchSchema = z.array(
z.object({
id: z.string().optional(),
label: z.string(),
iconclass: z.string(), // This is in format sl-icon-type-{type}
}),
);
@@ -0,0 +1,98 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import {
speedtestTrackerLatestResultEnvelopeSchema,
speedtestTrackerResultsCollectionSchema,
speedtestTrackerStatsEnvelopeSchema,
} from "./speedtest-tracker-types";
import type {
SpeedtestTrackerDashboardData,
SpeedtestTrackerResult,
SpeedtestTrackerResultsCollection,
SpeedtestTrackerStats,
} from "./speedtest-tracker-types";
export class SpeedtestTrackerIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/api/v1/results/latest");
const response = await input.fetchAsync(url, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return { success: true };
}
public async getLatestResultAsync(): Promise<SpeedtestTrackerResult | null> {
const response = await this.getAsync("/api/v1/results/latest");
// Speedtest Tracker returns 404 when no tests have been run yet (fresh install)
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new ResponseError(response);
}
return speedtestTrackerLatestResultEnvelopeSchema.parse(await response.json()).data;
}
public async getStatsAsync(): Promise<SpeedtestTrackerStats | null> {
const response = await this.getAsync("/api/v1/stats");
if (!response.ok) {
throw new ResponseError(response);
}
return speedtestTrackerStatsEnvelopeSchema.parse(await response.json()).data;
}
public async getRecentResultsAsync(perPage = 10): Promise<SpeedtestTrackerResultsCollection> {
const response = await this.getAsync("/api/v1/results", {
result_count: String(perPage),
sort: "-created_at",
});
if (!response.ok) {
throw new ResponseError(response);
}
return speedtestTrackerResultsCollectionSchema.parse(await response.json());
}
public async getDashboardDataAsync(): Promise<SpeedtestTrackerDashboardData> {
const [latestResult, stats, recentCollection] = await Promise.all([
this.getLatestResultAsync(),
this.getStatsAsync(),
this.getRecentResultsAsync(30),
]);
return {
latestResult,
stats,
recentResults: recentCollection.data,
};
}
private async getAsync(path: `/api/v1/${string}`, queryParams?: Record<string, string>) {
return await fetchWithTrustedCertificatesAsync(this.url(path, queryParams), {
headers: this.getAuthHeaders(),
});
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
Accept: "application/json",
};
}
}
@@ -0,0 +1,58 @@
import { z } from "zod/v4";
const createEnvelopeSchema = <T extends z.ZodType>(dataSchema: T) => z.object({ data: dataSchema });
const parseTimestamp = (timestamp: string): Date => new Date(`${timestamp.replace(" ", "T")}Z`);
export const speedtestTrackerResultSchema = z.object({
id: z.number().int(),
ping: z.number().nullable(),
download_bits: z.number().int().nullish(),
upload_bits: z.number().int().nullish(),
healthy: z.boolean().nullable(),
created_at: z.string().transform(parseTimestamp),
});
export type SpeedtestTrackerResult = z.infer<typeof speedtestTrackerResultSchema>;
export const speedtestTrackerLatestResultEnvelopeSchema = createEnvelopeSchema(speedtestTrackerResultSchema);
export const speedtestTrackerStatsBandwidthSchema = z.object({
avg: z.number(),
avg_bits: z.number().optional(),
min: z.number(),
max: z.number(),
});
export const speedtestTrackerStatsSchema = z.object({
ping: z.object({
avg: z.number(),
min: z.number(),
max: z.number(),
}),
download: speedtestTrackerStatsBandwidthSchema,
upload: speedtestTrackerStatsBandwidthSchema,
total_results: z.number().int(),
});
export type SpeedtestTrackerStats = z.infer<typeof speedtestTrackerStatsSchema>;
export const speedtestTrackerStatsEnvelopeSchema = createEnvelopeSchema(speedtestTrackerStatsSchema);
export const speedtestTrackerResultsCollectionSchema = z.object({
data: z.array(speedtestTrackerResultSchema),
meta: z.object({
current_page: z.number().int(),
last_page: z.number().int(),
total: z.number().int(),
per_page: z.number().int().optional(),
}),
});
export type SpeedtestTrackerResultsCollection = z.infer<typeof speedtestTrackerResultsCollectionSchema>;
export interface SpeedtestTrackerDashboardData {
latestResult: SpeedtestTrackerResult | null;
stats: SpeedtestTrackerStats | null;
recentResults: SpeedtestTrackerResult[];
}
+3
View File
@@ -12,4 +12,7 @@ export * from "./unifi-controller/unifi-controller-types";
export * from "./opnsense/opnsense-types";
export * from "./interfaces/media-releases";
export * from "./coolify/coolify-types";
export * from "./anchor/anchor-types";
export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
@@ -0,0 +1,454 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { NextcloudIntegration } from "../src";
// Mock the database
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/db")>();
return {
...actual,
db: createDb(),
};
});
// Mock certificate verification
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => {
return Promise.resolve([]);
}),
};
});
const IMAGE_NAME = "nextcloud:latest";
const DEFAULT_USERNAME = "admin";
const DEFAULT_PASSWORD = "admin123";
interface CalendarTestEvent {
summary: string;
description?: string;
location?: string;
color?: string;
dtstart: string;
dtend: string;
rrule?: string;
timezone?: string;
}
describe("Nextcloud integration", () => {
let startedContainer: StartedTestContainer | null = null;
let nextcloudIntegration: NextcloudIntegration | null = null;
beforeAll(async () => {
const containerRuntimeClient = await getContainerRuntimeClient();
await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME));
startedContainer = await prepareNextcloudContainerAsync();
nextcloudIntegration = createNextcloudIntegration(startedContainer);
}, 100_000);
afterAll(async () => {
if (startedContainer) {
await startedContainer.stop();
startedContainer = null;
}
});
describe("Test connection", () => {
test("Test connection > Should work with valid credentials", async () => {
// Act
const result = await nextcloudIntegration!.testConnectionAsync();
// Assert
expect(result.success).toBe(true);
}, 20_000);
test("Test connection > Should fail with invalid credentials", async () => {
// Arrange
const invalidIntegration = createNextcloudIntegration(startedContainer!, "wrong-user", "wrong-pass");
// Act
const result = await invalidIntegration.testConnectionAsync();
// Assert
expect(result.success).toBe(false);
}, 20_000);
});
describe("Retrieve calendar events", () => {
test("Retrieve calendar events > Should retrieve all fields correctly", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Test Meeting",
description: "This is a test meeting",
location: "Conference Room A",
color: "#FF5733",
dtstart: "20260415T140000Z",
dtend: "20260415T150000Z",
};
const start = new Date("2026-04-01T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(1);
const retrievedEvent = relevantEvents[0];
expect(retrievedEvent).toMatchObject({
title: testEvent.summary,
description: testEvent.description,
location: testEvent.location,
indicatorColor: testEvent.color,
});
expect(retrievedEvent!.startDate.toISOString()).toBe("2026-04-15T14:00:00.000Z");
expect(retrievedEvent!.endDate?.toISOString()).toBe("2026-04-15T15:00:00.000Z");
}, 20_000);
test("Retrieve calendar events > Should work with only required fields", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Simple Event",
dtstart: "20260415T140000Z",
dtend: "20260415T150000Z",
};
const start = new Date("2026-04-01T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(1);
const retrievedEvent = relevantEvents[0];
expect(retrievedEvent?.location).toBeNull();
expect(retrievedEvent?.description).toBeNull();
expect(retrievedEvent?.indicatorColor).toBe("#ff8600"); // Default color
}, 20_000);
test("Retrieve calendar events > Should work with timezone specified", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Timezone Event",
dtstart: "20260415T140000",
dtend: "20260415T150000",
timezone: "Asia/Tokyo",
};
const start = new Date("2026-04-01T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(1);
const retrievedEvent = relevantEvents[0];
expect(retrievedEvent!.startDate.toISOString()).toBe("2026-04-15T05:00:00.000Z"); // 14:00 in Tokyo is 05:00 UTC
expect(retrievedEvent!.endDate?.toISOString()).toBe("2026-04-15T06:00:00.000Z"); // 15:00 in Tokyo is 06:00 UTC
}, 120_000);
test("Retrieve calendar events > Should work with event that starts before the range but ends within the range", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Early Meeting",
dtstart: "20260401T230000Z", // Starts before the range
dtend: "20260402T010000Z", // Ends within the range
};
const start = new Date("2026-04-02T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(1);
}, 20_000);
test("Retrieve calendar events > Should work with event that starts within the range but ends after the range", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Late Meeting",
dtstart: "20260430T230000Z", // Starts within the range
dtend: "20260501T010000Z", // Ends after the range
};
const start = new Date("2026-04-01T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(1);
}, 20_000);
test("Retrieve calendar events > Should not retrieve events outside the range", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Out of Range Meeting",
dtstart: "20260301T140000Z", // Starts before the range
dtend: "20260301T150000Z", // Ends before the range
};
const start = new Date("2026-04-01T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(0);
}, 20_000);
test("Retrieve calendar events > Should work with events that begin before the range and end after the range", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Spanning Meeting",
dtstart: "20260330T230000Z", // Starts before the range
dtend: "20260501T010000Z", // Ends after the range
};
const start = new Date("2026-04-01T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(1);
}, 20_000);
test("Retrieve calendar events > Should work with recurring events first occurrence before the range", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Recurring Meeting",
dtstart: "20260330T140000Z", // First occurrence starts before the range
dtend: "20260330T150000Z",
rrule: "FREQ=DAILY;COUNT=5", // Daily for 5 occurrences
};
const start = new Date("2026-04-01T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(3);
}, 20_000);
test("Retrieve calendar events > Should work with recurring events that go beyond the range", async () => {
// Arrange
const calendarName = createId();
const testEvent: CalendarTestEvent = {
summary: "Working Week",
dtstart: "20260302T090000Z",
dtend: "20260306T170000Z",
rrule: "FREQ=WEEKLY;COUNT=10",
};
const start = new Date("2026-04-01T00:00:00Z");
const end = new Date("2026-04-30T23:59:59Z");
await createCalendarAsync(startedContainer!, calendarName);
await createCalendarEventAsync(startedContainer!, calendarName, testEvent);
// Act
const events = await nextcloudIntegration!.getCalendarEventsAsync(start, end);
// Assert
const relevantEvents = events.filter((event) => event.title === testEvent.summary);
expect(relevantEvents).toHaveLength(5);
});
});
});
/**
* Prepares a Nextcloud container with proper configuration
*/
async function prepareNextcloudContainerAsync(): Promise<StartedTestContainer> {
const container = createNextcloudContainer();
const startedContainer = await container.start();
return startedContainer;
}
/**
* Creates a Nextcloud container with proper configuration
*/
function createNextcloudContainer() {
return new GenericContainer(IMAGE_NAME)
.withEnvironment({
NEXTCLOUD_ADMIN_USER: DEFAULT_USERNAME,
NEXTCLOUD_ADMIN_PASSWORD: DEFAULT_PASSWORD,
NEXTCLOUD_TRUSTED_DOMAINS: "*",
SQLITE_DATABASE: "nextcloud", // Use SQLite for faster startup
})
.withExposedPorts(80)
.withWaitStrategy(Wait.forHttp("/status.php", 80).forStatusCode(200))
.withStartupTimeout(120_000);
}
/**
* Creates a Nextcloud integration instance
*/
function createNextcloudIntegration(
container: StartedTestContainer,
username?: string,
password?: string,
): NextcloudIntegration {
return new NextcloudIntegration({
id: "1",
decryptedSecrets: [
{
kind: "username",
value: username ?? DEFAULT_USERNAME,
},
{
kind: "password",
value: password ?? DEFAULT_PASSWORD,
},
],
name: "Nextcloud",
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
externalUrl: null,
});
}
/**
* Creates a calendar in Nextcloud via OCS API
*/
async function createCalendarAsync(container: StartedTestContainer, calendarName: string): Promise<void> {
const host = container.getHost();
const port = container.getMappedPort(80);
const url = `http://${host}:${port}/remote.php/dav/calendars/${DEFAULT_USERNAME}/${calendarName}`;
const auth = Buffer.from(`${DEFAULT_USERNAME}:${DEFAULT_PASSWORD}`).toString("base64");
const response = await fetch(url, {
method: "MKCALENDAR",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/xml",
},
body: `<?xml version="1.0" encoding="UTF-8"?>
<c:mkcalendar xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
<d:set>
<d:prop>
<d:displayname>${calendarName}</d:displayname>
</d:prop>
</d:set>
</c:mkcalendar>`,
});
if (!response.ok && response.status !== 201) {
throw new Error(`Failed to create calendar: ${response.status} ${response.statusText}`);
}
}
/**
* Creates a calendar event in Nextcloud via CalDAV
*/
async function createCalendarEventAsync(
container: StartedTestContainer,
calendarName: string,
event: CalendarTestEvent,
): Promise<void> {
const host = container.getHost();
const port = container.getMappedPort(80);
const id = createId();
const url = `http://${host}:${port}/remote.php/dav/calendars/${DEFAULT_USERNAME}/${calendarName}/${id}.ics`;
const auth = Buffer.from(`${DEFAULT_USERNAME}:${DEFAULT_PASSWORD}`).toString("base64");
// Build iCalendar format
const icalData = buildICalEvent(id, event);
const response = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "text/calendar",
},
body: icalData,
});
if (!response.ok && response.status !== 201) {
throw new Error(`Failed to create event: ${response.status} ${response.statusText}`);
}
}
/**
* Builds an iCalendar format string from event data
*/
function buildICalEvent(uid: string, event: CalendarTestEvent): string {
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Homarr Tests//EN",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
`DTSTART${event.timezone ? `;TZID=${event.timezone}` : ""}:${event.dtstart}`,
`DTEND${event.timezone ? `;TZID=${event.timezone}` : ""}:${event.dtend}`,
`SUMMARY:${event.summary}`,
];
if (event.description) {
lines.push(`DESCRIPTION:${event.description}`);
}
if (event.location) {
lines.push(`LOCATION:${event.location}`);
}
if (event.color) {
lines.push(`COLOR:${event.color}`);
}
if (event.rrule) {
lines.push(`RRULE:${event.rrule}`);
}
lines.push("END:VEVENT", "END:VCALENDAR");
return lines.join("\r\n");
}
+2 -2
View File
@@ -34,9 +34,9 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.18",
"@tabler/icons-react": "^3.40.0",
"@tabler/icons-react": "^3.41.1",
"dayjs": "^1.11.20",
"next": "16.2.1",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.3.6"
+1 -1
View File
@@ -25,7 +25,7 @@
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^8.3.18",
"@tabler/icons-react": "^3.40.0"
"@tabler/icons-react": "^3.41.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
+2 -2
View File
@@ -39,8 +39,8 @@
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"adm-zip": "0.5.16",
"next": "16.2.1",
"adm-zip": "0.5.17",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"superjson": "2.2.6",
+1 -2
View File
@@ -16,7 +16,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
({
id,
password,
salt,
salt: _saltIsNotNeededAsPartOfPassword,
settings,
...user
}): InferInsertModel<typeof users> & { oldId: string; isAdmin: boolean } => ({
@@ -30,7 +30,6 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
pingIconsEnabled: settings?.replacePingWithIcons,
isAdmin: user.isAdmin || user.isOwner,
password: decryptSecretWithKey(password, key),
salt: decryptSecretWithKey(salt, key),
}),
);
};
@@ -122,6 +122,7 @@ const optionMapping: OptionMapping = {
maximumAmountPosts: (oldOptions) => oldOptions.maximumAmountOfPosts,
textLinesClamp: (oldOptions) => oldOptions.textLinesClamp,
hideDescription: () => undefined,
showPosterImage: () => true, // This was the default in oldmarr
},
notebook: {
allowReadOnlyCheck: (oldOptions) => oldOptions.allowReadOnlyCheck,
@@ -0,0 +1,30 @@
import dayjs from "dayjs";
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const anchorNotesListRequestHandler = createCachedIntegrationRequestHandler<
AnchorNoteSummary[],
"anchor",
AnchorNotesListInput
>({
queryKey: "anchorNotesList",
cacheDuration: dayjs.duration(15, "seconds"),
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.listNotesAsync(input);
},
});
export const anchorNoteRequestHandler = createCachedIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>(
{
queryKey: "anchorNote",
cacheDuration: dayjs.duration(15, "seconds"),
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.getNoteAsync(input.noteId);
},
},
);
+2 -1
View File
@@ -28,7 +28,7 @@ export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({
return {
...result,
entries: result.entries?.slice(0, input.count) ?? [],
entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [],
};
},
cacheDuration: dayjs.duration(5, "minutes"),
@@ -109,6 +109,7 @@ const getFirstMediaProperty = (feedObject: object) => {
* This interface adds properties on top of the default ones.
*/
export interface ExtendedFeedEntry extends FeedEntry {
feedUrl: string;
enclosure?: string;
}
@@ -0,0 +1,19 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHandler<
SpeedtestTrackerDashboardData,
"speedtestTracker",
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "speedtestTrackerDashboard",
});
+36
View File
@@ -0,0 +1,36 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { Station, Timetable } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const timetableSearchStationsRequestHandler = createCachedIntegrationRequestHandler<
Station[],
IntegrationKindByCategory<"timetable">,
{ query: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.searchStationsAsync(input.query);
},
queryKey: "timetableSearchStations",
cacheDuration: dayjs.duration(1, "day"),
});
export const timetableGetTimetableRequestHandler = createCachedIntegrationRequestHandler<
Timetable,
IntegrationKindByCategory<"timetable">,
{ stationId: string; limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTimetableAsync({
stationId: input.stationId,
limit: input.limit,
});
},
queryKey: "timetableGetTimetable",
cacheDuration: dayjs.duration(1, "minute"),
});
+1 -1
View File
@@ -26,7 +26,7 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/dates": "^8.3.18",
"next": "16.2.1",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4"
},
+2 -2
View File
@@ -36,9 +36,9 @@
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/spotlight": "^8.3.18",
"@tabler/icons-react": "^3.40.0",
"@tabler/icons-react": "^3.41.1",
"jotai": "^2.19.0",
"next": "16.2.1",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"use-deep-compare-effect": "^1.8.1"
+2 -2
View File
@@ -32,8 +32,8 @@
"dayjs": "^1.11.20",
"deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.9",
"next": "16.2.1",
"next-intl": "4.8.3",
"next": "16.2.3",
"next-intl": "4.9.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "",
"description": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": "离线"
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "笔记",
"description": "支持 Markdown 的简单笔记本小部件",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "Minecraft 服务状态"
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": "图标更新"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": "crwdns2494:0crwdne2494:0"
}
},
"timetable": {
"name": "crwdns4000:0crwdne4000:0",
"description": "crwdns4002:0crwdne4002:0",
"title": "crwdns4004:0{station}crwdne4004:0",
"noStation": "crwdns4006:0crwdne4006:0",
"option": {
"station": {
"label": "crwdns4008:0crwdne4008:0"
}
}
},
"notebook": {
"name": "crwdns860:0crwdne860:0",
"description": "crwdns862:0crwdne862:0",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "crwdns2496:0crwdne2496:0"
},
"timetable": {
"label": "crwdns4010:0crwdne4010:0"
},
"iconsUpdater": {
"label": "crwdns1828:0crwdne1828:0"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Zápisník",
"description": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
+109 -95
View File
@@ -1138,9 +1138,9 @@
"dangerZone": "Farezone",
"noResults": "Ingen resultater fundet",
"unsavedChanges": "Du har ikke gemte ændringer!",
"servers": "",
"applications": "",
"services": "",
"servers": "Servere",
"applications": "Apps",
"services": "Services",
"preview": {
"show": "Vis forhåndsvisning",
"hide": "Skjul forhåndsvisning"
@@ -1525,6 +1525,17 @@
"offline": "Offline"
}
},
"timetable": {
"name": "Tidsskema",
"description": "Viser afgangstider for en station",
"title": "Afgange {station}",
"noStation": "Ingen station valgt",
"option": {
"station": {
"label": "Station"
}
}
},
"notebook": {
"name": "Notesbog",
"description": "En simpel notebook widget, der understøtter markdown",
@@ -1627,7 +1638,7 @@
"label": "Tillad geolokalisering"
},
"allowModals": {
"label": ""
"label": "Tillad modaler"
}
},
"error": {
@@ -1862,7 +1873,7 @@
"label": "Vis hukommelsesoplysninger"
},
"gpu": {
"label": ""
"label": "Vis GPU info"
},
"showUptime": {
"label": "Vis Oppetid"
@@ -1950,19 +1961,19 @@
"description": "Statistik for dine containere (Denne widget kan kun tilføjes med administratorrettigheder)",
"option": {
"enableRowSorting": {
"label": ""
"label": "Aktiver sortering"
},
"defaultSort": {
"label": "",
"label": "Kolonne brugt til sortering som standard",
"option": {
"name": "",
"state": "",
"cpuUsage": "",
"memoryUsage": ""
"name": "Navn",
"state": "Tilstand",
"cpuUsage": "CPU-forbrug",
"memoryUsage": "Hukommelsesforbrug"
}
},
"descendingDefaultSort": {
"label": ""
"label": "Inverter sortering"
}
},
"error": {
@@ -1970,91 +1981,91 @@
}
},
"coolify": {
"name": "",
"description": "",
"name": "Coolify",
"description": "Oversigt over din Coolify instans med servere og applikationer",
"option": {
"showServers": {
"label": ""
"label": "Vis servere"
},
"showApplications": {
"label": ""
"label": "Vis applikationer"
},
"showServices": {
"label": ""
"label": "Vis services"
}
},
"empty": {
"servers": "",
"applications": "",
"services": ""
"servers": "Ingen servere fundet",
"applications": "Ingen applikationer fundet",
"services": "Ingen services fundet"
},
"footer": {
"updated": ""
"updated": "Opdateret {when}"
},
"error": {
"noIntegration": "",
"internalServerError": ""
"noIntegration": "Ingen Coolify integration konfigureret",
"internalServerError": "Kunne ikke hente Coolify data"
},
"server": {
"buildServer": ""
"buildServer": "Byg"
},
"instances": {
"title": ""
"title": "Instanser"
}
},
"tracearr": {
"name": "",
"description": "",
"name": "Tracearr",
"description": "Forbedret overvågning af medieserver med detaljerede stream og omkodningsdata",
"option": {
"showStreams": {
"label": ""
"label": "Vis aktive streams"
},
"showStats": {
"label": ""
"label": "Vis statistikoversigt"
},
"showRecentActivity": {
"label": ""
"label": "Vis seneste aktivitet"
},
"showViolations": {
"label": ""
"label": "Vis overtrædelser"
}
},
"stats": {
"activeStreams": "",
"totalUsers": "",
"transcodes": "",
"bandwidth": ""
"activeStreams": "Aktive streams",
"totalUsers": "Antal brugere i alt",
"transcodes": "Transkodninger",
"bandwidth": "Båndbredde"
},
"streams": {
"empty": "",
"user": "",
"media": "",
"state": "",
"quality": "",
"device": "",
"progress": "",
"empty": "Ingen aktive streams",
"user": "Bruger",
"media": "Medie",
"state": "Tilstand",
"quality": "Kvalitet",
"device": "Enhed",
"progress": "Fremskridt",
"videoDecision": {
"directPlay": "",
"transcode": "",
"directStream": ""
"directPlay": "Direkte afspilning",
"transcode": "Transkod",
"directStream": "Direkte stream"
}
},
"recentActivity": {
"title": "",
"empty": "",
"watched": "",
"partial": ""
"title": "Seneste aktivitet",
"empty": "Ingen nylig aktivitet",
"watched": "Sete",
"partial": "Delvis"
},
"violations": {
"title": "",
"empty": "",
"rule": "",
"user": ""
"title": "Overtrædelser",
"empty": "Ingen nylige overtrædelser",
"rule": "Regel",
"user": "Bruger"
},
"noSectionsEnabled": "",
"noSectionsEnabled": "Ingen sektioner aktiveret",
"error": {
"noIntegration": "",
"internalServerError": ""
"noIntegration": "Ingen Tracearr integration konfigureret",
"internalServerError": "Kunne ikke hente Tracearr data"
}
},
"common": {
@@ -2153,50 +2164,50 @@
}
},
"immich-serverStats": {
"name": "",
"description": "",
"name": "Immich Server Statistik",
"description": "Information om din Immich instans",
"option": {
"showUsers": {
"label": "",
"description": ""
"label": "Vis brugere",
"description": "Vis antal brugere på din instans"
},
"showPhotos": {
"label": "",
"description": ""
"label": "Vis billeder",
"description": "Vis antal billeder på din instans"
},
"showVideos": {
"label": "",
"description": ""
"label": "Vis videoer",
"description": "Vis antal videoer på din instans"
},
"showStorage": {
"label": "",
"description": ""
"label": "Vis lagerplads",
"description": "Vis den samlede mængde brugte lagerplads"
}
},
"users": "",
"photos": "",
"videos": "",
"storage": ""
"users": "Brugere",
"photos": "Billeder",
"videos": "Videoer",
"storage": "Brugt lagerplads"
},
"immich-albumCarousel": {
"name": "",
"name": "Immich album",
"option": {
"albumId": {
"label": "",
"description": ""
"label": "Album ID",
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'"
},
"rotationIntervalSeconds": {
"label": "",
"description": ""
"label": "Interval i sekunder",
"description": "Skift mellem billeder i albummet hvert X sekunder. Skal være mindst 1. Skal være under 3600."
},
"showPhotoInfo": {
"label": "",
"description": ""
"label": "Vis billedinfo",
"description": "Vis dato for billedet og det aktuelle indeks i bunden af diasshowet"
}
},
"description": "",
"noAlbumSelected": "",
"noPhotos": ""
"description": "Diasshow af billeder i dit Immich album",
"noAlbumSelected": "Intet album valgt, rediger widget og vælg et album",
"noPhotos": "Ingen fotos i dette album"
},
"downloads": {
"name": "Download Klient",
@@ -2731,7 +2742,7 @@
"option": {
"cpu": "CPU",
"memory": "Hukommelse",
"gpu": "",
"gpu": "GPU",
"network": "Netværk"
}
},
@@ -2748,39 +2759,39 @@
"card": {
"cpu": "CPU",
"memory": "MEM",
"gpu": "",
"gpu": "GPU",
"network": "NET",
"up": "OP",
"down": "NED"
}
},
"systemDisks": {
"name": "",
"description": "",
"name": "System diske",
"description": "Diskforbrug af dit system",
"option": {
"showTemperatureIfAvailable": {
"label": ""
"label": "Vis temperatur, hvis muligt"
},
"displayMode": {
"label": "",
"label": "Visningstilstand",
"option": {
"percentage": {
"label": ""
"label": "Procentdel (f.eks. 76 %)"
},
"absolute": {
"label": ""
"label": "Absolutte værdier (f.eks. 800GB / 1TB)"
},
"free": {
"label": ""
"label": "Fri pladsprocent (fx, 24% fri)"
}
}
},
"showBackgroundBar": {
"label": ""
"label": "Vis forbrugslinje i baggrunden"
}
},
"status": {
"unhealthy": ""
"unhealthy": "Usund"
}
}
},
@@ -3469,10 +3480,10 @@
}
},
"user": {
"title": "",
"title": "Brugere",
"enableGravatar": {
"label": "",
"description": ""
"label": "Aktiver Gravatar",
"description": "Falder tilbage til brugernes avatarer fra Libravatar/Gravatar, når ingen brugerdefineret avatar er valgt og en e-mail er konfigureret"
}
},
"search": {
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "Status for Minecraft server"
},
"timetable": {
"label": "Tidsskema"
},
"iconsUpdater": {
"label": "Opdatering Af Ikoner"
},
@@ -3583,7 +3597,7 @@
"label": "Vejr"
},
"tracearr": {
"label": ""
"label": "Tracearr"
}
},
"interval": {
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Notizbuch",
"description": "Ein einfaches Notizbuch Widget, das Markdown unterstützt",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "Status des Minecraft Servers"
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": "Symbol Updater"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": "Offline"
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Notizbuch",
"description": "Ein einfaches Notizbuch Widget, das Markdown unterstützt",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "Status des Minecraft Servers"
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": "Symbol Updater"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Σημειωματάριο",
"description": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
+166 -152
View File
@@ -1525,6 +1525,17 @@
"offline": "Offline"
}
},
"timetable": {
"name": "Timetable",
"description": "Displays departure times for a station",
"title": "Departures {station}",
"noStation": "No station selected",
"option": {
"station": {
"label": "Station"
}
}
},
"notebook": {
"name": "Notebook",
"description": "A simple notebook widget that supports markdown",
@@ -1627,7 +1638,7 @@
"label": "Allow geolocation"
},
"allowModals": {
"label": ""
"label": "Allow modals"
}
},
"error": {
@@ -1859,284 +1870,284 @@
"label": "Show CPU Info"
},
"memory": {
"label": ""
"label": "Show Memory Info"
},
"gpu": {
"label": ""
"label": "Show GPU Info"
},
"showUptime": {
"label": ""
"label": "Show Uptime"
},
"fileSystem": {
"label": ""
"label": "Show Filesystem Info"
},
"defaultTab": {
"label": ""
"label": "Default tab"
},
"visibleClusterSections": {
"label": ""
"label": "Visible cluster sections"
},
"sectionIndicatorRequirement": {
"label": ""
"label": "Section indicator requirement"
}
},
"popover": {
"information": "",
"processor": "",
"memory": "",
"memoryAvailable": "",
"version": "",
"uptime": "",
"loadAverage": "",
"minute": "",
"minutes": "",
"used": "",
"available": "",
"lastSeen": ""
"information": "Information",
"processor": "Processor: {cpuModelName}",
"memory": "Memory: {memory}GiB",
"memoryAvailable": "Available: {memoryAvailable}GiB ({percent}%)",
"version": "Version: {version}",
"uptime": "Uptime: {months} Months, {days} Days, {hours} Hours, {minutes} Minutes",
"loadAverage": "Load average:",
"minute": "1 minute",
"minutes": "{count} minutes",
"used": "Used",
"available": "Available",
"lastSeen": "Last status update: {lastSeen}"
},
"memory": {},
"error": {
"internalServerError": ""
"internalServerError": "Failed to fetch health status"
},
"cluster": {
"summary": {
"cpu": "",
"memory": ""
"cpu": "CPU",
"memory": "RAM"
},
"resource": {
"node": {
"name": ""
"name": "Nodes"
},
"qemu": {
"name": ""
"name": "VMs"
},
"lxc": {
"name": ""
"name": "LXCs"
},
"storage": {
"name": ""
"name": "Storage"
}
},
"popover": {
"rightSection": {
"node": "",
"vmId": "",
"plugin": ""
"node": "Node",
"vmId": "VM ID",
"plugin": "Plugin"
},
"detail": {
"cpu": "",
"memory": "",
"storage": "",
"uptime": "",
"haState": "",
"cpu": "Cores",
"memory": "Memory",
"storage": "Storage",
"uptime": "Uptime",
"haState": "HA State",
"storageType": {
"local": "",
"shared": ""
"local": "Local storage",
"shared": "Shared storage"
}
}
},
"table": {
"header": {
"name": "",
"cpu": "",
"memory": "",
"node": ""
"name": "Name",
"cpu": "CPU",
"memory": "RAM",
"node": "Node"
}
}
}
},
"dockerContainers": {
"name": "",
"description": "",
"name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": {
"enableRowSorting": {
"label": ""
"label": "Enable items sorting"
},
"defaultSort": {
"label": "",
"label": "Column used for sorting by default",
"option": {
"name": "",
"state": "",
"cpuUsage": "",
"memoryUsage": ""
"name": "Name",
"state": "State",
"cpuUsage": "CPU usage",
"memoryUsage": "Memory usage"
}
},
"descendingDefaultSort": {
"label": ""
"label": "Invert sorting"
}
},
"error": {
"internalServerError": ""
"internalServerError": "Failed to fetch containers stats"
}
},
"coolify": {
"name": "",
"description": "",
"name": "Coolify",
"description": "Overview of your Coolify instance with servers and applications",
"option": {
"showServers": {
"label": ""
"label": "Show servers"
},
"showApplications": {
"label": ""
"label": "Show applications"
},
"showServices": {
"label": ""
"label": "Show services"
}
},
"empty": {
"servers": "",
"applications": "",
"services": ""
"servers": "No servers found",
"applications": "No applications found",
"services": "No services found"
},
"footer": {
"updated": ""
"updated": "Updated {when}"
},
"error": {
"noIntegration": "",
"internalServerError": ""
"noIntegration": "No Coolify integration configured",
"internalServerError": "Failed to fetch Coolify data"
},
"server": {
"buildServer": ""
"buildServer": "Build"
},
"instances": {
"title": ""
"title": "Instances"
}
},
"tracearr": {
"name": "",
"description": "",
"name": "Tracearr",
"description": "Enhanced media server monitoring with detailed stream and transcoding data",
"option": {
"showStreams": {
"label": ""
"label": "Show active streams"
},
"showStats": {
"label": ""
"label": "Show stats summary"
},
"showRecentActivity": {
"label": ""
"label": "Show recent activity"
},
"showViolations": {
"label": ""
"label": "Show violations"
}
},
"stats": {
"activeStreams": "",
"totalUsers": "",
"transcodes": "",
"bandwidth": ""
"activeStreams": "Active Streams",
"totalUsers": "Total Users",
"transcodes": "Transcodes",
"bandwidth": "Bandwidth"
},
"streams": {
"empty": "",
"user": "",
"media": "",
"state": "",
"quality": "",
"device": "",
"progress": "",
"empty": "No active streams",
"user": "User",
"media": "Media",
"state": "State",
"quality": "Quality",
"device": "Device",
"progress": "Progress",
"videoDecision": {
"directPlay": "",
"transcode": "",
"directStream": ""
"directPlay": "Direct Play",
"transcode": "Transcode",
"directStream": "Direct Stream"
}
},
"recentActivity": {
"title": "",
"empty": "",
"watched": "",
"partial": ""
"title": "Recent Activity",
"empty": "No recent activity",
"watched": "Watched",
"partial": "Partial"
},
"violations": {
"title": "",
"empty": "",
"rule": "",
"user": ""
"title": "Violations",
"empty": "No recent violations",
"rule": "Rule",
"user": "User"
},
"noSectionsEnabled": "",
"noSectionsEnabled": "No sections enabled",
"error": {
"noIntegration": "",
"internalServerError": ""
"noIntegration": "No Tracearr integration configured",
"internalServerError": "Failed to fetch Tracearr data"
}
},
"common": {
"location": {
"query": "",
"latitude": "",
"longitude": "",
"disabledTooltip": "",
"unknownLocation": "",
"search": "",
"query": "City / Postal code",
"latitude": "Latitude",
"longitude": "Longitude",
"disabledTooltip": "Please enter a city or postal code",
"unknownLocation": "Unknown location",
"search": "Search",
"table": {
"header": {
"city": "",
"country": "",
"coordinates": "",
"population": ""
"city": "City",
"country": "Country",
"coordinates": "Coordinates",
"population": "Population"
},
"action": {
"select": ""
"select": "Select {city}, {countryCode}"
},
"population": {
"fallback": ""
"fallback": "Unknown"
}
}
},
"integration": {
"noData": "",
"description": ""
"noData": "No integration found",
"description": "Click <here></here> to create a new integration"
},
"app": {
"noData": "",
"description": "",
"quickCreate": ""
"noData": "No app found",
"description": "Click <here></here> to create a new app",
"quickCreate": "Create app on the fly"
},
"error": {
"noIntegration": "",
"noData": ""
"noIntegration": "No integration selected",
"noData": "No integration data available"
},
"option": {}
},
"video": {
"name": "",
"description": "",
"name": "Video Stream",
"description": "Embed a video stream or video from a camera or a website",
"option": {
"feedUrl": {
"label": ""
"label": "Feed URL"
},
"hasAutoPlay": {
"label": "",
"description": ""
"label": "Autoplay",
"description": "Autoplay only works when muted because of browser restrictions"
},
"isMuted": {
"label": ""
"label": "Muted"
},
"hasControls": {
"label": ""
"label": "Show controls"
}
},
"error": {
"noUrl": "",
"forYoutubeUseIframe": ""
"noUrl": "No Video URL provided",
"forYoutubeUseIframe": "For YouTube videos use the iframe option"
}
},
"mediaServer": {
"name": "",
"description": "",
"name": "Current media server streams",
"description": "Show the current streams on your media servers",
"option": {
"showOnlyPlaying": {
"label": "",
"description": ""
"label": "Show only currently playing",
"description": "Disabling this will not work for Plex"
}
},
"items": {
"currentlyPlaying": "",
"user": "",
"name": "",
"id": "",
"currentlyPlaying": "Currently playing",
"user": "User",
"name": "Name",
"id": "Id",
"metadata": {
"title": "",
"title": "Stats for nerds",
"video": {
"title": "",
"resolution": ""
"title": "Video",
"resolution": "Resolution"
},
"audio": {
"title": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
@@ -4321,13 +4335,13 @@
}
},
"newUser": {
"label": ""
"label": "Create a new user"
},
"newInvite": {
"label": ""
"label": "Create a new invite"
},
"newGroup": {
"label": ""
"label": "Create a new group"
}
}
}
@@ -4370,12 +4384,12 @@
"description": ""
},
"torrent": {
"name": "",
"description": ""
"name": "Torrents",
"description": "Search for torrents on torrentdownloads.pro"
},
"youTube": {
"name": "",
"description": ""
"name": "YouTube",
"description": "Search for videos on YouTube"
}
}
}
@@ -4384,19 +4398,19 @@
"help": {
"group": {
"mode": {
"title": ""
"title": "Modes"
},
"help": {
"title": "",
"title": "Help",
"option": {
"documentation": {
"label": ""
"label": "Documentation"
},
"submitIssue": {
"label": ""
"label": "Submit an issue"
},
"discord": {
"label": ""
"label": "Community Discord"
}
}
}
@@ -4405,20 +4419,20 @@
"home": {
"group": {
"search": {
"title": "",
"title": "Search",
"option": {
"other": {
"label": ""
"label": "Search with another search engine"
},
"no-default": {
"label": "",
"description": ""
"label": "No default search engine",
"description": "Set a default search engine in preferences"
},
"search": {
"label": ""
"label": "Search for \"{query}\" with {name}"
},
"from-integration": {
"description": ""
"description": "Start typing to search"
}
}
},
+84
View File
@@ -1525,6 +1525,17 @@
"offline": "Offline"
}
},
"timetable": {
"name": "Timetable",
"description": "Displays departure times for a station",
"title": "Departures {station}",
"noStation": "No station selected",
"option": {
"station": {
"label": "Station"
}
}
},
"notebook": {
"name": "Notebook",
"description": "A simple notebook widget that supports markdown",
@@ -1595,6 +1606,34 @@
}
}
},
"anchorNote": {
"name": "Anchor Note",
"description": "Displays a selected note from Anchor",
"option": {
"noteId": {
"label": "Note"
},
"showTitle": {
"label": "Show title"
},
"showUpdatedAt": {
"label": "Show last updated"
}
},
"empty": "Select a note to display",
"emptyContent": "This note has no content",
"notFound": "Note not found",
"noNotes": "No notes found",
"integrationRequired": "Select an Anchor integration first",
"updatedAt": "Updated {date}",
"untitled": "Untitled",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"readOnlyViewer": "You have viewer access to this note. Editing is disabled.",
"saveForbidden": "You do not have permission to edit this note.",
"saveFailed": "Failed to save this note. Please try again."
},
"iframe": {
"name": "iFrame",
"description": "Embed any content from the internet. Some websites may restrict access.",
@@ -2505,6 +2544,9 @@
},
"hideDescription": {
"label": "Hide description"
},
"showPosterImage": {
"label": "Show poster image"
}
}
},
@@ -2718,6 +2760,45 @@
"noItems": "No notifications to display.",
"option": {}
},
"speedtestTracker": {
"name": "Speedtest Tracker",
"description": "Displays speed test results from your Speedtest Tracker instance.",
"option": {
"showLatestResult": {
"label": "Show latest result"
},
"showStats": {
"label": "Show averages"
},
"showRecentResults": {
"label": "Show recent results"
},
"showPingGraph": {
"label": "Show ping graph"
}
},
"latestResult": "Latest Result",
"averages": "Averages",
"tests": "{count} tests",
"recentResults": "Recent Results",
"recentResultsSpeed": "Speed",
"recentResultsPing": "Ping",
"download": "Download",
"upload": "Upload",
"ping": "Ping",
"status": "Status",
"avgDownload": "Avg Download",
"avgUpload": "Avg Upload",
"avgPing": "Avg Ping",
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"ok": "OK",
"fail": "Fail",
"noSectionsEnabled": "Enable at least one section in widget options.",
"error": {
"internalServerError": "Failed to fetch speedtest data"
}
},
"systemResources": {
"name": "System resources",
"description": "CPU, Memory, Disk and other hardware usage of your system",
@@ -3513,6 +3594,9 @@
"minecraftServerStatus": {
"label": "Minecraft server status"
},
"timetable": {
"label": "Timetable"
},
"iconsUpdater": {
"label": "Icons Updater"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": "Desconectado"
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Bloc de notas",
"description": "Un widget de bloc de notas sencillo que soporta Markdown",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "Estado del Servidor de Minecraft"
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": "Actualizador de iconos"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "",
"description": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
+42 -28
View File
@@ -114,7 +114,7 @@
},
"invite": {
"title": "Rejoignez Homarr",
"subtitle": "Bienvenue sur Homarr! Veuillez créer votre compte",
"subtitle": "Bienvenue sur Homarr ! Veuillez créer votre compte",
"description": "Vous avez été invité par {username}"
},
"init": {
@@ -177,7 +177,7 @@
}
},
"forgotPassword": {
"label": "Mot de passe oublié?",
"label": "Mot de passe oublié ?",
"description": "Un administrateur peut utiliser la commande suivante pour réinitialiser votre mot de passe :"
}
},
@@ -578,7 +578,7 @@
},
"delete": {
"title": "Supprimer l'app",
"message": "Êtes-vous sûr de vouloir supprimer l'app {name}?",
"message": "Êtes-vous sûr de vouloir supprimer l'app {name} ?",
"notification": {
"success": {
"title": "Suppression effectuée avec succès",
@@ -684,7 +684,7 @@
},
"delete": {
"title": "Supprimer l'intégration",
"message": "Êtes-vous sûr(e) de vouloir supprimer l'intégration {name}?",
"message": "Êtes-vous sûr(e) de vouloir supprimer l'intégration {name} ?",
"notification": {
"success": {
"title": "Suppression réussie",
@@ -742,7 +742,7 @@
"description": "La réponse n'a pas pu être analysée. Veuillez vérifier que l'URL pointe vers l'URL de base du service."
},
"authorization": {
"title": "",
"title": "Erreur d'autorisation",
"description": "La requête n'a pas été autorisée. Veuillez vérifier que les informations d'identification sont correctes et que vous les avez configurées avec suffisamment d'autorisations."
},
"statusCode": {
@@ -794,7 +794,7 @@
"hostnameMismatch": {
"confirm": {
"title": "Faire confiant à l'incohérence du nom d'hôte",
"message": ""
"message": "Êtes-vous sûr de vouloir faire confiance ce certificat avec un nom d'hôte incohérent ?"
},
"notification": {
"success": {
@@ -1164,8 +1164,8 @@
"number": "Ce champ doit être inférieur ou égal à {maximum}"
},
"custom": {
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas.",
"passwordRequirements": "Le mot de passe ne respecte pas les exigences requises.",
"passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"passwordRequirements": "Le mot de passe ne respecte pas les exigences requises",
"boardAlreadyExists": "Un tableau avec ce nom existe déjà",
"invalidFileType": "Type de fichier invalide, {expected} attendu",
"invalidFileName": "Nom de fichier invalide",
@@ -1248,7 +1248,7 @@
"action": {
"create": "Nouvel élément",
"import": "Importer un élément",
"edit": "Editer l'élément",
"edit": "Éditer l'élément",
"moveResize": "Déplacer / redimensionner l'élément",
"duplicate": "Dupliquer l'élément",
"remove": "Supprimer l'élément"
@@ -1281,7 +1281,7 @@
}
},
"edit": {
"title": "Editer l'élément",
"title": "Éditer l'élément",
"advancedOptions": {
"label": "Options avancées",
"title": "Options avancées de l'élément"
@@ -1309,7 +1309,7 @@
"widget": {
"app": {
"name": "App",
"description": "Intégre une application dans le tableau.",
"description": "Intègre une application dans le tableau.",
"option": {
"appId": {
"label": "Choisir une application"
@@ -1525,6 +1525,17 @@
"offline": "Hors ligne"
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Bloc-notes",
"description": "Un simple widget bloc-notes qui supporte le markdown",
@@ -1637,7 +1648,7 @@
}
},
"smartHome-entityState": {
"name": "Etat de l'entité",
"name": "État de l'entité",
"description": "Affichez l'état d'une entité et optionnellement activez la",
"option": {
"entityId": {
@@ -1883,16 +1894,16 @@
"popover": {
"information": "Information",
"processor": "Processeur : {cpuModelName}",
"memory": "Mémoire: {memory}GiB",
"memoryAvailable": "Disponible: {memoryAvailable}GiB ({percent}%)",
"memory": "Mémoire : {memory}GiB",
"memoryAvailable": "Disponible : {memoryAvailable}GiB ({percent}%)",
"version": "Version : {version}",
"uptime": "Disponibilité: {months} Mois, {days} Jours, {hours} Heures, {minutes} Minutes",
"loadAverage": "Charge moyenne:",
"uptime": "Disponibilité : {months} Mois, {days} Jours, {hours} Heures, {minutes} Minutes",
"loadAverage": "Charge moyenne :",
"minute": "1 minute",
"minutes": "{count} minutes",
"used": "Utilisé ",
"available": "Disponible",
"lastSeen": "Dernière mise à jour de statut: {lastSeen}"
"lastSeen": "Dernière mise à jour de statut : {lastSeen}"
},
"memory": {},
"error": {
@@ -1928,7 +1939,7 @@
"memory": "Mémoire",
"storage": "Stockage",
"uptime": "Disponibilité",
"haState": "Etat HA",
"haState": "État HA",
"storageType": {
"local": "Stockage local",
"shared": "Stockage partagé"
@@ -2062,7 +2073,7 @@
"query": "Ville / Code Postal",
"latitude": "Latitude",
"longitude": "Longitude",
"disabledTooltip": "Veuillez saisir une ville ou un code postal.",
"disabledTooltip": "Veuillez saisir une ville ou un code postal",
"unknownLocation": "Lieu inconnu",
"search": "Rechercher",
"table": {
@@ -2081,7 +2092,7 @@
}
},
"integration": {
"noData": "Aucune intégration trouvée.",
"noData": "Aucune intégration trouvée",
"description": "Cliquez sur <here></here> pour créer une nouvelle intégration"
},
"app": {
@@ -2097,7 +2108,7 @@
},
"video": {
"name": "Flux vidéo",
"description": "Intégre un flux vidéo ou une vidéo provenant d'une caméra ou d'un site web",
"description": "Intègre un flux vidéo ou une vidéo provenant d'une caméra ou d'un site web",
"option": {
"feedUrl": {
"label": "URL du flux"
@@ -2110,7 +2121,7 @@
"label": "Muet"
},
"hasControls": {
"label": "Afficher les controles"
"label": "Afficher les contrôles"
}
},
"error": {
@@ -2336,7 +2347,7 @@
"pause": "Mettre en pause l'élément",
"resume": "Reprendre l'élément",
"delete": {
"title": "Supprimer l'élement",
"title": "Supprimer l'élément",
"modalTitle": "Êtes-vous sûr de vouloir supprimer cette tâche ?",
"entry": "Supprimer l'entrée",
"entryAndFiles": "Supprimer l'entrée et le(s) fichier(s)"
@@ -2548,7 +2559,7 @@
"allImagesAlreadyImported": "Toutes les images déjà importées",
"onlyAdminCanImport": "Seuls les administrateurs peuvent importer depuis docker",
"selectAll": "Tout sélectionner",
"deselectAll": "Tout déselectionner"
"deselectAll": "Tout désélectionner"
},
"provider": {
"label": "Fournisseur"
@@ -2673,7 +2684,7 @@
},
"error": {
"integrationsDisconnected": "Aucune donnée disponible, toutes les intégrations sont déconnectées",
"unknownContentOption": "Option de contenu inconnue pour le widget d'état du réseau: "
"unknownContentOption": "Option de contenu inconnue pour le widget d'état du réseau : "
},
"name": "État du réseau",
"description": "Afficher les périphériques connectés sur un réseau"
@@ -2765,7 +2776,7 @@
"label": "Mode d'affichage",
"option": {
"percentage": {
"label": "Pourcentage (ex : 70%)"
"label": "Pourcentage (ex : 76%)"
},
"absolute": {
"label": "Valeurs absolues (ex. : 800 Go / 1 To)"
@@ -2973,7 +2984,7 @@
},
"customCss": {
"label": "CSS personnalisé pour ce tableau",
"description": "En outre, vous pouvez personnaliser votre tableau de bord à l'aide de CSS. Réservé aux utilisateurs expérimentés.",
"description": "De plus, personnalisez votre tableau de bord à l'aide de CSS, recommandé uniquement pour les utilisateurs expérimentés",
"customClassesAlert": {
"title": "Classes personnalisées",
"description": "Vous pouvez ajouter des classes personnalisées aux éléments de votre tableau dans les options avancées de chaque élément et les utiliser dans le CSS personnalisé ci-dessus."
@@ -3251,7 +3262,7 @@
"label": "Supprimer définitivement",
"confirm": {
"title": "Supprimer le tableau de bord",
"description": "Êtes-vous sûr de vouloir supprimer le tableau {name}?"
"description": "Êtes-vous sûr de vouloir supprimer le tableau {name} ?"
}
}
},
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "État du serveur Minecraft"
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": "Mise à jour des icônes"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": "לא מקוון"
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "פנקס רשימות",
"description": "ווידג'ט פשוט של מחברת התומך ב-markdown",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "סטטוס שרת מיינקראפט"
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": "עדכון אייקונים"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Bilježnica",
"description": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
+104 -90
View File
@@ -51,7 +51,7 @@
"title": "Import token megadása",
"field": {
"token": {
"label": "",
"label": "Token",
"description": "Írja be a megjelenített import tokent az előző homarr példányból"
}
},
@@ -153,10 +153,10 @@
"label": "Ikonok használata a pingekhez"
},
"defaultSearchEngine": {
"label": ""
"label": "Alapértelmezett kereső"
},
"openSearchInNewTab": {
"label": ""
"label": "Nyissa meg a keresési eredményeket új ablakban"
}
},
"error": {
@@ -219,10 +219,10 @@
"changeSearchPreferences": {
"notification": {
"success": {
"message": ""
"message": "A keresési beállítások sikeresen megváltoztak"
},
"error": {
"message": ""
"message": "A keresési beállításokat nem sikerült megváltoztatni"
}
}
},
@@ -307,12 +307,12 @@
"name": "Név",
"members": "Tagok",
"homeBoard": {
"label": "",
"description": ""
"label": "Főoldal",
"description": "Csak a csoport számára elérhető oldalak választhatók ki"
},
"mobileBoard": {
"label": "",
"description": ""
"label": "Mobiloldal",
"description": "Csak a csoport számára elérhető oldalak választhatók ki"
}
},
"permission": {
@@ -514,12 +514,12 @@
"board": {
"notification": {
"success": {
"title": "",
"message": ""
"title": "Beállítások elmentve",
"message": "Oldal beállítások sikeresen elmentve"
},
"error": {
"title": "",
"message": ""
"title": "Nem sikerült menteni a beállításokat",
"message": "Az oldalbeállítások nem menthetők"
}
}
}
@@ -527,21 +527,21 @@
"changePosition": {
"notification": {
"success": {
"message": ""
"message": "Pozíció sikeresen megváltozott"
},
"error": {
"message": ""
"message": "Pozíció nem módosítható"
}
}
}
},
"defaultGroup": {
"name": "",
"description": ""
"name": "Alapértelmezett csoport",
"description": "{name} - Minden bejelentkezett felhasználó"
}
},
"app": {
"search": "",
"search": "Alkalmazás megtalálása",
"page": {
"list": {
"title": "Alkalmazások",
@@ -599,12 +599,12 @@
"label": "Leírás"
},
"url": {
"label": ""
"label": "Url"
},
"useDifferentUrlForPing": {
"checkbox": {
"label": "",
"description": ""
"label": "Más URL használata a ping-hez",
"description": "Hasznos, ha a Homarr közvetlenül, egy belső hosztnév vagy hálózaton keresztül fér hozzá, hogy elkerülje az internetszolgáltató által használt sávszélesség terhelését"
}
}
},
@@ -612,17 +612,17 @@
"select": {
"label": "Alkalmazás kiválasztása",
"notFound": "Nem található alkalmazás",
"search": "",
"noResults": "",
"action": "",
"title": ""
"search": "Alkalmazás keresése",
"noResults": "Nincs találat",
"action": "Kiválasztás {app}",
"title": "Válassz egy programot az oldalhoz adáshoz"
},
"create": {
"title": "",
"description": "",
"action": ""
"title": "Új alkalmazás készítése",
"description": "Új alkalmazás létrehozása ",
"action": "Alkalmazás készítő megnyitása"
},
"add": ""
"add": "Alkalmazás hozzáadása"
}
},
"integration": {
@@ -649,14 +649,14 @@
"app": {
"option": {
"existing": {
"title": "",
"label": ""
"title": "Meglévő",
"label": "Meglévő alkalmazás kiválasztása"
},
"new": {
"title": "",
"title": "Új",
"url": {
"label": "",
"description": ""
"label": "Alkalmazás url",
"description": "Az url, amelyre az alkalmazás a műszerfalról történő eléréskor irányít"
}
}
}
@@ -676,9 +676,9 @@
},
"app": {
"action": {
"add": "",
"remove": "",
"select": ""
"add": "Alkalmazás megtalálása",
"remove": "Leválasztás",
"select": "Válasszon ki egy alkalmazást a hivatkozáshoz"
}
}
},
@@ -702,21 +702,21 @@
"label": "Név"
},
"url": {
"label": ""
"label": "Url"
},
"attemptSearchEngineCreation": {
"label": "",
"description": ""
"label": "Keresőmotor létrehozása",
"description": "A \"{kind}\" integráció használható a keresőmotorokkal. Jelölje be ezt a jelölőnégyzetet, ha automatikusan szeretné konfigurálni a keresőmotort."
},
"app": {
"sectionTitle": ""
"sectionTitle": "Hivattkozott alkalmazás"
},
"createApp": {
"label": "",
"description": ""
"label": "Alkalmazás létrehozása",
"description": "Hozzon létre egy alkalmazást, amelynek ugyanaz a neve és ikonja van, mint az integrációnak. Hagyja üresen az alábbi beviteli mezőt, hogy az alkalmazás az integrációs URL-lel jöjjön létre."
},
"appHref": {
"placeholder": ""
"placeholder": "Egyéni alkalmazás URL"
}
},
"action": {
@@ -730,59 +730,59 @@
"error": {
"common": {
"cause": {
"title": ""
"title": "Részletesebb leírás a problémáról"
}
},
"unknown": {
"title": "",
"description": ""
"title": "Ismeretlen hiba",
"description": "Egy ismeretlen hiba történt, nyissa meg a részleteket alul, hogy többet megtudjon"
},
"parse": {
"title": "",
"description": ""
"title": "Elemzési hiba",
"description": "A válasz nem volt elemezhető. Kérjük, ellenőrizze, hogy az URL a szolgáltatás alap URL-jére mutat-e."
},
"authorization": {
"title": "",
"description": ""
"title": "Hitelesítési hiba",
"description": "A kérés nem volt engedélyezett. Kérjük, ellenőrizze, hogy a hitelesítő adatok helyesek-e, és hogy a megfelelő engedélyekkel vannak-e beállítva."
},
"statusCode": {
"title": "",
"description": "",
"otherDescription": "",
"title": "Válaszhiba",
"description": "Váratlan {statusCode} ({reason}) választ kapott a <url></url> címen. Kérjük, ellenőrizze, hogy az URL az integráció alap URL-jére mutat-e.",
"otherDescription": "Váratlan {statusCode} választ kapott a <url></url> címen. Kérjük, ellenőrizze, hogy az URL az integráció alap URL-jére mutat-e.",
"reason": {
"badRequest": "",
"notFound": "",
"tooManyRequests": "",
"internalServerError": "",
"serviceUnavailable": "",
"gatewayTimeout": ""
"badRequest": "Hibás kérés",
"notFound": "Nem található",
"tooManyRequests": "Túl sok kérés",
"internalServerError": "Belső szerverhiba",
"serviceUnavailable": "A szolgáltatás nem elérhető",
"gatewayTimeout": "Átjáró időtúllépés"
}
},
"certificate": {
"title": "",
"title": "Tanúsítványhiba",
"description": {
"expired": "",
"notYetValid": "",
"untrusted": "",
"hostnameMismatch": ""
"expired": "A tanúsítvány lejárt.",
"notYetValid": "A tanúsítvány még nem érvényes.",
"untrusted": "A tanúsítvány nem megbízható.",
"hostnameMismatch": "A tanúsítványban szereplő hosztnév nem egyezik meg az URL-lel."
},
"alert": {
"permission": {
"title": "",
"message": ""
"title": "Nincsenek megfelelő engedélyek",
"message": "Önnek nincs joga tanúsítványokat megbízhatónak nyilvánítani vagy feltölteni. Kérjük, lépjen kapcsolatba a rendszergazdájával, hogy feltölthesse a szükséges gyökér tanúsítványt."
},
"hostnameMismatch": {
"title": "",
"message": ""
"title": "Állomásnév eltérés",
"message": "A tanúsítványban szereplő hosztnév nem egyezik meg azzal a hosztnévvel, amelyhez kapcsolódik. Ez biztonsági kockázatot jelezhet, de mégis dönthet úgy, hogy megbízik ebben a tanúsítványban."
},
"extract": {
"title": "",
"message": ""
"title": "A tanúsító hatóság (CA) tanúsítványának kinyerése sikertelen",
"message": "Csak a lánc nélküli, önaláírt tanúsítványok tölthetők le automatikusan. Ha önaláírt tanúsítványt használ, kérjük, feltétlenül töltse fel manuálisan a tanúsító hatóság (CA) tanúsítványát. Az ehhez szükséges lépések leírását itt találja: <docsLink></docsLink>."
}
},
"action": {
"retry": {
"label": ""
"label": "Újrapróbál"
},
"trust": {
"label": ""
@@ -920,8 +920,8 @@
"message": "Az útvonal valószínűleg nem helyes"
},
"tooManyRequests": {
"title": "",
"message": ""
"title": "Túl sok kérés adott idő alatt",
"message": "Túl sok kérést küldött. Valószínűleg a célrendszer korlátozza vagy elutasítja a kéréseket"
}
}
},
@@ -955,36 +955,36 @@
"newLabel": "Új jelszó"
},
"tokenId": {
"label": "",
"newLabel": ""
"label": "Token azonosító",
"newLabel": "Új token azonosító"
},
"realm": {
"label": "",
"newLabel": ""
"label": "Tartomány",
"newLabel": "Új tartomány"
},
"personalAccessToken": {
"label": "",
"newLabel": ""
"label": "Személyes Hozzáférési Token",
"newLabel": "Új Személyes Hozzáférési Token"
},
"topic": {
"label": "",
"newLabel": ""
"label": "Téma",
"newLabel": "Új téma"
},
"url": {
"label": "",
"newLabel": ""
"label": "Url",
"newLabel": "Új url"
},
"opnsenseApiKey": {
"label": "",
"newLabel": ""
"label": "API kulcs (Kulcs)",
"newLabel": "Új API Kulcs (Kulcs)"
},
"opnsenseApiSecret": {
"label": "",
"newLabel": ""
"label": "Api Kulcs (Titkos)",
"newLabel": "Új API Kulcs (Titkos)"
},
"githubAppId": {
"label": "",
"newLabel": ""
"label": "App Azonosító",
"newLabel": "Új App Azonosító"
},
"githubInstallationId": {
"label": "",
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Jegyzettömb",
"description": "Egy egyszerű notebook widget, amely támogatja a leértékelést",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": "Offline"
}
},
"timetable": {
"name": "Orario",
"description": "Visualizza gli orari di partenza per una stazione",
"title": "Partenze {station}",
"noStation": "Nessuna stazione selezionata",
"option": {
"station": {
"label": "Stazione"
}
}
},
"notebook": {
"name": "Blocco note",
"description": "Un semplice widget notebook che supporta markdown",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "Stato del server Minecraft"
},
"timetable": {
"label": "Orario"
},
"iconsUpdater": {
"label": "Aggiornatore Icone"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": "オフライン"
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "メモ帳",
"description": "マークダウンをサポートするシンプルなノートブックウィジェット",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": "Minecraft サーバーの状態"
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": "アイコンアップデータ"
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "노트북",
"description": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Užrašai",
"description": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
+14
View File
@@ -1525,6 +1525,17 @@
"offline": ""
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"option": {
"station": {
"label": ""
}
}
},
"notebook": {
"name": "Piezīmes",
"description": "",
@@ -3513,6 +3524,9 @@
"minecraftServerStatus": {
"label": ""
},
"timetable": {
"label": ""
},
"iconsUpdater": {
"label": ""
},
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more