fix: address PR review findings for widget-only integration refactor

- Fix npm scoped package support (filter time metadata keys, make
  description optional, exclude falsy releases from version selection)
- Fix timetable date formatting (use Intl.DateTimeFormat for stable
  MM/DD/YYYY output) and named zod import
- Guard migration 0000 against unknown provider keys
- Fix migration 0003 to delete integration links by integrationId
- Reset immich album carousel index when photo list changes
- Add bounded limit to timetable router input
- Remove stale misspelled noProviderSeleceted translation key
- Fix provider name visibility in release widget settings
- Update docs: add ArchiveTeam Warrior note, fix typos, correct
  showPhotoInfo default
This commit is contained in:
Thomas Camlong committed 2026-06-27 10:57:13 +02:00
1 parent 62baaf7813
commit c892731479
44 files changed
+46 -76

No files matched your search

@@ -48,11 +48,11 @@ You may refresh anytime and continue from a different device - the onboarding is
### Starting from scratch
Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
If credentials provider was selected, you'll be prompted to create an admin user for Homarr.
This user cannot be deleted and has full control over Homarr and all of it's boards.
You must choose an username and password (both can later be changed).
We enforce strict password requirements, when you enter your password, to protect your instance if you accidentially
expose it to the Internet.
If the credentials provider was selected, you'll be prompted to create an admin user for Homarr.
This user cannot be deleted and has full control over Homarr and all of its boards.
You must choose a username and password (both can later be changed).
We enforce strict password requirements when you enter your password to protect your instance if you accidentally
expose it to the internet.
<img
src={imageStartFromScratchStepAdminUser}
@@ -62,8 +62,8 @@ expose it to the Internet.
If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group.
This group will be used to synchronize users from your provider to Homarr.
When a user is logged in through the provider the groups of him that match groups with any in Homarr will be added to the user.
The name of the group has to match exactly the name of the group in the provider.
When a user logs in through the provider, any of their groups that match groups in Homarr will be assigned to the user.
The name of the group must match exactly the name of the group in the provider.
<img
src={imageStartFromScratchStepExternalAdminGroup}
@@ -107,7 +107,7 @@ You can optionally auto-create bookmark apps for all discovered Docker container
:::note
Release providers and Search.ch are configured directly in their widgets instead of the integrations page.
Release providers, Search.ch, and ArchiveTeam Warrior are configured directly in their widgets instead of the integrations page.
:::
@@ -24,7 +24,7 @@ export const immichAlbumCarouselWidget: WidgetDefinition = {
name: "Show photo info",
description: "Display date of the picture and the current index at the bottom of the slideshow",
values: { type: "boolean" },
defaultValue: "yes",
defaultValue: "no",
},
{
name: "Randomize photos",
+1 -1
View File
@@ -15,7 +15,7 @@ export const timetableRouter = createTRPCRouter({
z.object({
baseUrl: baseUrlSchema,
stationId: z.string(),
limit: z.number(),
limit: z.number().int().min(1).max(100),
}),
)
.query(async ({ input }) => {
@@ -23,18 +23,19 @@ export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) {
if (options.repositories.length === 0) continue;
if (!options.repositories.some((repository) => "providerKey" in repository)) continue;
const updatedRepositories = options.repositories.map(
({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
// Ensure providerKey is camelCase
const updatedRepositories = options.repositories
.map(({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
const provider = providerKey.charAt(0).toLowerCase() + providerKey.slice(1);
const matchedProvider = releaseProviderKinds.find((kind) => kind === provider);
if (!matchedProvider) return null;
return {
id: createId(),
provider: releaseProviderKinds.find((kind) => kind === provider),
provider: matchedProvider,
...otherFields,
};
},
);
})
.filter((repo) => repo !== null);
updates.push({
id: item.id,
@@ -54,21 +54,11 @@ export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database)
.filter((item) => oldIntegrationById.has(item.integrationId))
.map((item) => [item.itemId, oldIntegrationById.get(item.integrationId)]),
);
const oldIntegrationItemIdsByItemId = oldIntegrationItems
.filter((item) => oldIntegrationById.has(item.integrationId))
.reduce<Map<string, string[]>>((acc, item) => {
const itemIds = acc.get(item.itemId) ?? [];
itemIds.push(item.integrationId);
acc.set(item.itemId, itemIds);
return acc;
}, new Map());
const existingItems = await db.query.items.findMany({
where: (items, { inArray }) => inArray(items.kind, ["releases", "timetable"]),
});
const updatedItems: { id: string; options: object }[] = [];
const unlinkItemIds: string[] = [];
for (const item of existingItems) {
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
@@ -89,12 +79,10 @@ export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database)
};
});
updatedItems.push({ id: item.id, options: { ...options, repositories } });
if (oldIntegrationItemIdsByItemId.has(item.id)) unlinkItemIds.push(item.id);
}
if (item.kind === "timetable" && linkedIntegration?.kind === "searchCh") {
updatedItems.push({ id: item.id, options: { ...options, baseUrl: linkedIntegration.url } });
unlinkItemIds.push(item.id);
}
}
@@ -105,10 +93,7 @@ export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database)
.where(eq(items.id, update.id));
}
if (unlinkItemIds.length > 0) {
await db.delete(integrationItems).where(inArray(integrationItems.itemId, unlinkItemIds));
}
await db.delete(integrationItems).where(inArray(integrationItems.integrationId, oldIntegrationIds));
await db.delete(integrations).where(inArray(integrations.id, oldIntegrationIds));
console.log(`Migrated widget-only integrations to widget options count="${updatedItems.length}"`);
@@ -46,8 +46,8 @@ export const getLatestRelease = (
versionRegex?: string,
): ReleaseProviderResponse | null => {
const validReleases = releases.filter((result) => {
if (result.latestRelease) return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true;
return true;
if (!result.latestRelease) return false;
return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true;
});
return validReleases.length === 0
@@ -443,16 +443,20 @@ const getGitlabDetailsAsync = async (baseUrl: string, encodedIdentifier: string)
};
};
const npmTimeMetadataKeys = new Set(["created", "modified"]);
const npmReleasesSchema = z.object({
time: z
.record(
z.string(),
z.string().transform((value) => new Date(value)),
)
.transform((version) =>
Object.entries(version).map(([key, value]) => ({ latestRelease: key, latestReleaseAt: value })),
.transform((entries) =>
Object.entries(entries)
.filter(([key]) => !npmTimeMetadataKeys.has(key))
.map(([key, value]) => ({ latestRelease: key, latestReleaseAt: value })),
),
versions: z.record(z.string(), z.object({ description: z.string() })),
versions: z.record(z.string(), z.object({ description: z.string().optional() })),
name: z.string(),
});
+14 -3
View File
@@ -1,5 +1,5 @@
import dayjs from "dayjs";
import z from "zod/v4";
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
@@ -88,7 +88,18 @@ const getTimetableAsync = async (
options: { stationId: string; limit: number },
): Promise<Timetable> => {
const now = new Date();
const [date, time] = now.toLocaleString("en-CH", { timeZone: "Europe/Zurich" }).split(", ") as [string, string];
const date = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Zurich",
month: "2-digit",
day: "2-digit",
year: "numeric",
}).format(now);
const time = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Zurich",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(now);
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
stop: options.stationId,
@@ -96,7 +107,7 @@ const getTimetableAsync = async (
show_delays: 1,
show_tracks: 1,
date,
time: time.substring(0, 5),
time,
}),
);
if (!response.ok) throw new ResponseError(response);
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "无效标识符",
"noMatchingVersion": "没有找到匹配的版本",
"noReleasesFound": "没有发现发布信息",
"noProviderSeleceted": "没有选择提供者",
"noProviderResponse": "提供者没有响应",
"noProviderSelected": "没有选择提供者"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "crwdns3512:0crwdne3512:0",
"noMatchingVersion": "crwdns3514:0crwdne3514:0",
"noReleasesFound": "crwdns3516:0crwdne3516:0",
"noProviderSeleceted": "crwdns3518:0crwdne3518:0",
"noProviderResponse": "crwdns3520:0crwdne3520:0",
"noProviderSelected": "crwdns3518:0crwdne3518:0"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Ugyldig identifikator",
"noMatchingVersion": "Ingen matchende version fundet",
"noReleasesFound": "Ingen udgivelser fundet",
"noProviderSeleceted": "Ingen udbyder valgt",
"noProviderResponse": "Intet svar fra udbyder",
"noProviderSelected": "Ingen udbyder valgt"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Ungültige Kennung",
"noMatchingVersion": "Keine passende Version gefunden",
"noReleasesFound": "Keine Veröffentlichungen gefunden",
"noProviderSeleceted": "Kein Anbieter ausgewählt",
"noProviderResponse": "Keine Antwort vom Anbieter",
"noProviderSelected": "Kein Anbieter ausgewählt"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Identificador no válido",
"noMatchingVersion": "No se encontró una versión correspondiente",
"noReleasesFound": "No se encontraron publicaciones",
"noProviderSeleceted": "No se ha seleccionado ningún proveedor",
"noProviderResponse": "No hay respuesta del proveedor",
"noProviderSelected": "No se ha seleccionado ningún proveedor"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "Toimittajaa ei ole valittu",
"noProviderResponse": "",
"noProviderSelected": "Toimittajaa ei ole valittu"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Identifiant non valide",
"noMatchingVersion": "Aucune version correspondante trouvée",
"noReleasesFound": "Aucune publication trouvée",
"noProviderSeleceted": "Aucun fournisseur sélectionné",
"noProviderResponse": "Aucune réponse du fournisseur",
"noProviderSelected": "Aucun fournisseur sélectionné"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "מזהה לא חוקי",
"noMatchingVersion": "לא נמצאה גרסה תואמת",
"noReleasesFound": "לא נמצאו שיחרורים",
"noProviderSeleceted": "לא נבחר ספק",
"noProviderResponse": "אין תשובה מהספק",
"noProviderSelected": "לא נבחר ספק"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Identificatore non valido",
"noMatchingVersion": "Nessuna versione corrispondente trovata",
"noReleasesFound": "Nessun rilascio trovato",
"noProviderSeleceted": "Nessun provider selezionato",
"noProviderResponse": "Nessuna risposta dal provider",
"noProviderSelected": "Nessun provider selezionato"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "無効な識別子",
"noMatchingVersion": "一致するバージョンが見つかりません",
"noReleasesFound": "リリースが見つかりませんでした",
"noProviderSeleceted": "プロバイダーが選択されていません",
"noProviderResponse": "プロバイダーからの応答がありません",
"noProviderSelected": "プロバイダーが選択されていません"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Ongeldige identificator",
"noMatchingVersion": "Geen overeenkomende versie gevonden",
"noReleasesFound": "Geen uitgaves gevonden",
"noProviderSeleceted": "Geen aanbieder geselecteerd",
"noProviderResponse": "Geen reactie van aanbieder",
"noProviderSelected": "Geen aanbieder geselecteerd"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Ugyldig identifikator",
"noMatchingVersion": "Ingen samsvarende versjon funnet",
"noReleasesFound": "Ingen utgivelser funnet",
"noProviderSeleceted": "Ingen leverandør valgt",
"noProviderResponse": "Ingen respons fra leverandør",
"noProviderSelected": "Ingen leverandør valgt"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Nieprawidłowy identyfikator",
"noMatchingVersion": "Nie znaleziono pasującej wersji",
"noReleasesFound": "Nie znaleziono wydań!",
"noProviderSeleceted": "Nie wybrano dostawcy",
"noProviderResponse": "Brak odpowiedzi od dostawcy",
"noProviderSelected": "Nie wybrano dostawcy"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Identificador inválido",
"noMatchingVersion": "Nenhuma versão correspondente encontrada",
"noReleasesFound": "Nenhum lançamento encontrado",
"noProviderSeleceted": "Nenhum provedor selecionado",
"noProviderResponse": "Nenhuma resposta do provedor",
"noProviderSelected": "Nenhum provedor selecionado"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Неверный идентификатор",
"noMatchingVersion": "Подходящая версия не найдена",
"noReleasesFound": "Релизы не найдены",
"noProviderSeleceted": "Провайдер не выбран",
"noProviderResponse": "Нет ответа от провайдера",
"noProviderSelected": "Провайдер не выбран"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "Nenašla sa žiadna zodpovedajúca verzia",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "Geçersiz tanımlayıcı",
"noMatchingVersion": "Eşleşen sürüm bulunamadı",
"noReleasesFound": "Hiçbir sürüm bulunamadı",
"noProviderSeleceted": "Hiçbir sağlayıcı seçilmedi",
"noProviderResponse": "Sağlayıcıdan yanıt alınamadı",
"noProviderSelected": "Hiçbir sağlayıcı seçilmedi"
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
-1
View File
@@ -3205,7 +3205,6 @@
"invalidIdentifier": "",
"noMatchingVersion": "找不到匹配的版本",
"noReleasesFound": "",
"noProviderSeleceted": "",
"noProviderResponse": "",
"noProviderSelected": ""
}
@@ -204,7 +204,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
}}
/>
<Text c="dimmed" fw={100} size="xs">
<Text c="dimmed" size="xs">
{providerName}
</Text>
@@ -31,6 +31,10 @@ export default function ImmichAlbumCarouselWidget({
return options.randomizePhotos ? shuffle(assets) : assets;
}, [album?.assets, options.randomizePhotos]);
useEffect(() => {
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}