prismcast 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +9 -8
- package/dist/app.js.map +1 -1
- package/dist/browser/login.js +11 -8
- package/dist/browser/login.js.map +1 -1
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.js +208 -22
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.js +10 -15
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +9 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providers.js +30 -17
- package/dist/config/providers.js.map +1 -1
- package/dist/config/userChannels.d.ts +62 -9
- package/dist/config/userChannels.js +172 -44
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -0
- package/dist/config/userConfig.js +29 -4
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.js +27 -39
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/native/index.d.ts +2 -2
- package/dist/native/index.js +14 -24
- package/dist/native/index.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -2
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +26 -48
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/channels.js +5 -11
- package/dist/routes/channels.js.map +1 -1
- package/dist/routes/components.d.ts +0 -8
- package/dist/routes/components.js +0 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +1 -1
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +335 -35
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +17 -2
- package/dist/routes/config/channels/table.js +270 -66
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/providers.js +7 -7
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/config/settings.js +57 -36
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/icons.d.ts +16 -0
- package/dist/routes/icons.js +44 -0
- package/dist/routes/icons.js.map +1 -0
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +68 -27
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +41 -2
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +185 -0
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +115 -41
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +69 -23
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +6 -12
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +45 -4
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +1 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +3 -35
- package/dist/service/commands.js.map +1 -1
- package/dist/streaming/codec.d.ts +20 -0
- package/dist/streaming/codec.js +68 -0
- package/dist/streaming/codec.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +2 -2
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.js +11 -6
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +2 -2
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/preroll.d.ts +15 -14
- package/dist/streaming/preroll.js +31 -11
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.d.ts +2 -2
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +2 -2
- package/dist/streaming/setup.js +5 -12
- package/dist/streaming/setup.js.map +1 -1
- package/dist/types/channels.d.ts +3 -0
- package/dist/types/config.d.ts +1 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -15
- package/dist/types/streaming.js +5 -1
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -16
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/utils/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/format.d.ts +14 -3
- package/dist/utils/format.js +24 -6
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/retry.js +2 -4
- package/dist/utils/retry.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString } from "../../../utils/index.js";
|
|
2
|
-
import {
|
|
1
|
+
import { LOG, formatError, generateChannelKey, parseM3U, sanitizeString, stringifySorted } from "../../../utils/index.js";
|
|
2
|
+
import { PREDEFINED_CHANNELS, PREDEFINED_TAGS } from "../../../channels/index.js";
|
|
3
|
+
import { VALID_OPTIONAL_COLUMNS, buildChannelTablePatch, buildChannelTableState, generateTagFilterContent, generateTagManagerBody } from "./table.js";
|
|
3
4
|
import { VALID_SORT_FIELDS, compareChannelSort, getAllProviderTags, getCanonicalKey, getChannelProviderLabel, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, resolvePredefinedVariant, resolveProviderKey, setEnabledProviders, setProviderSelection } from "../../../config/providers.js";
|
|
4
5
|
import { filterDefaults, loadUserConfig, saveUserConfig } from "../../../config/userConfig.js";
|
|
5
|
-
import { getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveUserChannels, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
|
|
6
|
+
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getEastWithPacificPredefinedKeys, getPacificPredefinedKeys, getPredefinedChannel, getPredefinedChannels, getTagRegistry, getUserChannels, isPredefinedChannel, isUserChannel, loadUserChannels, resolveStoredChannel, saveProviderSelections, saveTagRegistry, saveUserChannels, setTagRegistry, transformChannelTags, validateChannelKey, validateChannelName, validateChannelNumber, validateChannelProfile, validateChannelUrl, validateImportedChannels } from "../../../config/userChannels.js";
|
|
6
7
|
import { CONFIG } from "../../../config/index.js";
|
|
7
|
-
import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
|
|
8
8
|
import { getProfiles } from "../../../config/profiles.js";
|
|
9
9
|
import { updateChannelLogo } from "../../../streaming/showInfo.js";
|
|
10
10
|
// Fields that appear in the generated M3U playlist and affect Channels DVR's view of the channel. Used to decide whether the playlist reload hint is shown.
|
|
@@ -77,7 +77,7 @@ export function setupChannelRoutes(app) {
|
|
|
77
77
|
}
|
|
78
78
|
res.setHeader("Content-Type", "application/json");
|
|
79
79
|
res.setHeader("Content-Disposition", "attachment; filename=\"prismcast-channels.json\"");
|
|
80
|
-
res.send(
|
|
80
|
+
res.send(stringifySorted(resolved) + "\n");
|
|
81
81
|
}
|
|
82
82
|
catch (error) {
|
|
83
83
|
LOG.error("Failed to export channels: %s.", formatError(error));
|
|
@@ -246,14 +246,16 @@ export function setupChannelRoutes(app) {
|
|
|
246
246
|
let switched = 0;
|
|
247
247
|
let channelsChanged = false;
|
|
248
248
|
let selectionsChanged = false;
|
|
249
|
-
// Build a UserChannel object from an entry's fields. Shared by the switch/enable and add paths to avoid duplicating channel construction logic.
|
|
250
|
-
|
|
249
|
+
// Build a UserChannel object from an entry's fields. Shared by the switch/enable and add paths to avoid duplicating channel construction logic. When
|
|
250
|
+
// canonicalKey is provided, the channel is a provider variant — identity fields (name, stationId) are omitted because they're resolved from the canonical
|
|
251
|
+
// at runtime via applyVariantInheritance. Standalone channels (no canonicalKey) include all fields.
|
|
252
|
+
function buildUserChannel(entry, channelName, channelUrl, selector, canonicalKey) {
|
|
251
253
|
const channel = {
|
|
254
|
+
...(canonicalKey ? { canonicalKey } : { name: channelName }),
|
|
252
255
|
channelSelector: selector || undefined,
|
|
253
|
-
name: channelName,
|
|
254
256
|
url: channelUrl
|
|
255
257
|
};
|
|
256
|
-
if (entry.stationId) {
|
|
258
|
+
if (!canonicalKey && entry.stationId) {
|
|
257
259
|
channel.stationId = sanitizeString(entry.stationId);
|
|
258
260
|
}
|
|
259
261
|
return channel;
|
|
@@ -277,9 +279,10 @@ export function setupChannelRoutes(app) {
|
|
|
277
279
|
await enablePredefinedChannels([canonicalKey]);
|
|
278
280
|
}
|
|
279
281
|
const variantKey = canonicalKey + "-" + providerSlug;
|
|
280
|
-
// If no variant exists for this provider, create one as a user channel.
|
|
282
|
+
// If no variant exists for this provider, create one as a user channel. The canonicalKey parameter tells buildUserChannel to produce a variant
|
|
283
|
+
// (provider-specific fields only, no identity fields).
|
|
281
284
|
if (!allKeys.has(variantKey)) {
|
|
282
|
-
existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""));
|
|
285
|
+
existingChannels[variantKey] = buildUserChannel(entry, name, sanitizeString(entry.url?.trim() ?? ""), sanitizeString(entry.channelSelector?.trim() ?? ""), canonicalKey);
|
|
283
286
|
allKeys.add(variantKey);
|
|
284
287
|
channelsChanged = true;
|
|
285
288
|
}
|
|
@@ -346,12 +349,9 @@ export function setupChannelRoutes(app) {
|
|
|
346
349
|
if (allKeys.has(key)) {
|
|
347
350
|
continue;
|
|
348
351
|
}
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
if (key !== baseKey) {
|
|
353
|
-
newChannel.canonicalKey = baseKey;
|
|
354
|
-
}
|
|
352
|
+
// When the key differs from baseKey, this is a provider variant — pass baseKey as canonicalKey so buildUserChannel produces a variant with only
|
|
353
|
+
// provider-specific fields. Standalone channels (key === baseKey) get the full channel object with identity fields.
|
|
354
|
+
const newChannel = buildUserChannel(entry, name, url, channelSelector, (key !== baseKey) ? baseKey : undefined);
|
|
355
355
|
existingChannels[key] = newChannel;
|
|
356
356
|
allKeys.add(key);
|
|
357
357
|
channelsChanged = true;
|
|
@@ -571,11 +571,10 @@ export function setupChannelRoutes(app) {
|
|
|
571
571
|
}
|
|
572
572
|
const affectedKeys = [];
|
|
573
573
|
if (clearMode) {
|
|
574
|
-
// Clear channel numbers from all visible channels.
|
|
574
|
+
// Clear channel numbers from all visible channels. Null signals "clear this field" — the normalizer in saveUserChannels() handles the storage conventions.
|
|
575
575
|
for (const entry of listing) {
|
|
576
576
|
const existing = result.channels[entry.key] ?? {};
|
|
577
|
-
|
|
578
|
-
existing.channelNumber = clearValue;
|
|
577
|
+
existing.channelNumber = null;
|
|
579
578
|
result.channels[entry.key] = existing;
|
|
580
579
|
affectedKeys.push(entry.key);
|
|
581
580
|
}
|
|
@@ -630,8 +629,7 @@ export function setupChannelRoutes(app) {
|
|
|
630
629
|
continue;
|
|
631
630
|
}
|
|
632
631
|
const existing = result.channels[entry.key] ?? {};
|
|
633
|
-
|
|
634
|
-
existing.hdhrEnabled = enable ? clearValue : false;
|
|
632
|
+
existing.hdhrEnabled = enable ? null : false;
|
|
635
633
|
result.channels[entry.key] = existing;
|
|
636
634
|
affectedKeys.push(entry.key);
|
|
637
635
|
}
|
|
@@ -651,6 +649,55 @@ export function setupChannelRoutes(app) {
|
|
|
651
649
|
res.status(500).json({ error: "Failed to toggle HDHR settings: " + formatError(error), success: false });
|
|
652
650
|
}
|
|
653
651
|
});
|
|
652
|
+
// POST /config/channels/bulk-tags - Add or remove a tag on all enabled, provider-available channels. Operates on the same channel set as other bulk actions.
|
|
653
|
+
// transformChannelTags handles loading, delta normalization, and persistence. Returns a channel table patch for all affected rows and an updated tag manager
|
|
654
|
+
// modal body.
|
|
655
|
+
app.post("/config/channels/bulk-tags", async (req, res) => {
|
|
656
|
+
try {
|
|
657
|
+
const body = req.body;
|
|
658
|
+
const action = body.action;
|
|
659
|
+
const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
|
|
660
|
+
if ((action !== "add") && (action !== "remove")) {
|
|
661
|
+
res.status(400).json({ error: "Action must be 'add' or 'remove'.", success: false });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (!tag) {
|
|
665
|
+
res.status(400).json({ error: "Tag is required.", success: false });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
// Validate the tag is in the active vocabulary.
|
|
669
|
+
const vocabulary = new Set(getActiveTagVocabulary());
|
|
670
|
+
if (!vocabulary.has(tag)) {
|
|
671
|
+
res.status(400).json({ error: "Unknown tag: " + tag + ".", success: false });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.enabled && entry.availableByProvider, (tags) => (action === "add") ? (tags.includes(tag) ? tags : [...tags, tag]) : tags.filter((t) => t !== tag));
|
|
675
|
+
if (error) {
|
|
676
|
+
res.status(400).json({ error, success: false });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (affectedKeys.length === 0) {
|
|
680
|
+
res.json({ affected: 0, message: "No changes needed.", success: true });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const verb = (action === "add") ? "added to" : "removed from";
|
|
684
|
+
LOG.info("Bulk tag %s: %s on %d channels.", action, tag, affectedKeys.length);
|
|
685
|
+
const profiles = getProfiles();
|
|
686
|
+
const message = "Tag '" + tag + "' " + verb + " " + String(affectedKeys.length) + " channel(s).";
|
|
687
|
+
res.json({
|
|
688
|
+
affected: affectedKeys.length,
|
|
689
|
+
filterContent: generateTagFilterContent(),
|
|
690
|
+
message,
|
|
691
|
+
modalBody: generateTagManagerBody(),
|
|
692
|
+
patch: buildChannelTablePatch(affectedKeys, profiles),
|
|
693
|
+
success: true
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
LOG.error("Failed to bulk-update tags: %s.", formatError(error));
|
|
698
|
+
res.status(500).json({ error: "Failed to update tags: " + formatError(error), success: false });
|
|
699
|
+
}
|
|
700
|
+
});
|
|
654
701
|
// POST /config/provider-filter - Update the provider filter (enabled provider tags).
|
|
655
702
|
app.post("/config/provider-filter", async (req, res) => {
|
|
656
703
|
try {
|
|
@@ -890,7 +937,7 @@ export function setupChannelRoutes(app) {
|
|
|
890
937
|
}
|
|
891
938
|
const field = body.field;
|
|
892
939
|
const value = sanitizeString(body.value ?? "");
|
|
893
|
-
if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId")) {
|
|
940
|
+
if ((field !== "channelNumber") && (field !== "hdhrEnabled") && (field !== "stationId") && (field !== "tags")) {
|
|
894
941
|
res.status(400).json({ message: "Invalid inline-edit field.", success: false });
|
|
895
942
|
return;
|
|
896
943
|
}
|
|
@@ -908,23 +955,28 @@ export function setupChannelRoutes(app) {
|
|
|
908
955
|
res.status(400).json({ message: "Cannot edit: channels file contains invalid JSON.", success: false });
|
|
909
956
|
return;
|
|
910
957
|
}
|
|
911
|
-
// Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet.
|
|
958
|
+
// Use a StoredChannelMap-compatible update. The ?? {} creates an empty ChannelDelta for predefined channels that don't have a user override yet. Null signals
|
|
959
|
+
// "clear this field" for all channel types — the normalizer in saveUserChannels() handles delta comparison for predefined channels and null-stripping for
|
|
960
|
+
// user channels.
|
|
912
961
|
const stored = result.channels[key] ?? {};
|
|
913
|
-
const clearValue = isPredefinedChannel(key) ? null : undefined;
|
|
914
962
|
if (field === "channelNumber") {
|
|
915
|
-
stored.channelNumber = value ? parseInt(value, 10) :
|
|
963
|
+
stored.channelNumber = value ? parseInt(value, 10) : null;
|
|
916
964
|
}
|
|
917
965
|
else if (field === "hdhrEnabled") {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
966
|
+
stored.hdhrEnabled = (value === "false") ? false : null;
|
|
967
|
+
}
|
|
968
|
+
else if (field === "tags") {
|
|
969
|
+
const tags = value ? [...new Set(value.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
|
|
970
|
+
stored.tags = (tags.length > 0) ? tags : null;
|
|
921
971
|
}
|
|
922
972
|
else {
|
|
923
|
-
stored.stationId = value ||
|
|
973
|
+
stored.stationId = value || null;
|
|
924
974
|
}
|
|
925
975
|
result.channels[key] = stored;
|
|
926
976
|
await saveUserChannels(result.channels);
|
|
927
|
-
const fieldLabels = {
|
|
977
|
+
const fieldLabels = {
|
|
978
|
+
channelNumber: "Channel number", hdhrEnabled: "HDHR lineup", stationId: "Station ID", tags: "Tags"
|
|
979
|
+
};
|
|
928
980
|
const fieldLabel = fieldLabels[field] ?? field;
|
|
929
981
|
const displayValue = (field === "hdhrEnabled") ? ((value === "false") ? "excluded" : "included") : (value || "(cleared)");
|
|
930
982
|
LOG.info("Inline edit: %s for '%s' set to '%s'.", fieldLabel, key, displayValue);
|
|
@@ -977,6 +1029,9 @@ export function setupChannelRoutes(app) {
|
|
|
977
1029
|
const channelSelector = sanitizeString(body.channelSelector ?? "");
|
|
978
1030
|
const channelNumberStr = sanitizeString(body.channelNumber ?? "");
|
|
979
1031
|
const hdhrEnabled = body.hdhrEnabled !== "false";
|
|
1032
|
+
// Parse tags from comma-separated input. Trim, lowercase, filter empty, sort, deduplicate.
|
|
1033
|
+
const tagsRaw = sanitizeString(body.tags ?? "");
|
|
1034
|
+
const tags = tagsRaw ? [...new Set(tagsRaw.split(",").map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))].sort() : [];
|
|
980
1035
|
// Validate channel number if provided.
|
|
981
1036
|
const channelNumberError = validateChannelNumber(channelNumberStr, key);
|
|
982
1037
|
if (channelNumberError) {
|
|
@@ -1048,14 +1103,19 @@ export function setupChannelRoutes(app) {
|
|
|
1048
1103
|
// First check: did the user change anything from what the form showed? The edit form is pre-populated with the selected provider's resolved channel, which
|
|
1049
1104
|
// may differ from the canonical predefined base when a variant is selected (e.g., the Hulu variant has a different URL and channelSelector). If the submitted
|
|
1050
1105
|
// values match the displayChannel exactly, the user saved without modification — no override should be created, and any existing override is preserved.
|
|
1106
|
+
// Tags are compared separately (array comparison) after the scalar fields.
|
|
1051
1107
|
const resolvedKey = resolveProviderKey(key);
|
|
1052
1108
|
const displayChannel = getResolvedChannel(resolvedKey) ?? predefinedBase;
|
|
1053
|
-
|
|
1109
|
+
const displayTags = getChannelEffectiveTags(displayChannel);
|
|
1110
|
+
const scalarUnchanged = Object.keys(formValues).every((field) => formValues[field] === channelValue(displayChannel, field));
|
|
1111
|
+
const tagsUnchanged = JSON.stringify(tags) === JSON.stringify(displayTags);
|
|
1112
|
+
if (scalarUnchanged && tagsUnchanged) {
|
|
1054
1113
|
res.json({ key, message: "No changes to save.", success: true });
|
|
1055
1114
|
return;
|
|
1056
1115
|
}
|
|
1057
1116
|
// Second check: compute a delta against the canonical predefined base. This determines whether the user's changes create a custom override or effectively
|
|
1058
|
-
// revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear).
|
|
1117
|
+
// revert the channel to predefined defaults. Changed fields store their new value; empty/undefined fields store null (explicit clear). Tags are handled
|
|
1118
|
+
// separately from the scalar loop since they require array comparison.
|
|
1059
1119
|
const delta = {};
|
|
1060
1120
|
let hasChanges = false;
|
|
1061
1121
|
for (const field of Object.keys(formValues)) {
|
|
@@ -1065,8 +1125,17 @@ export function setupChannelRoutes(app) {
|
|
|
1065
1125
|
hasChanges = true;
|
|
1066
1126
|
}
|
|
1067
1127
|
}
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1128
|
+
// Tags delta: compare the submitted tags against the predefined base's vocabulary-filtered tags. Using effective tags (not raw) ensures that editing an
|
|
1129
|
+
// unrelated field while a predefined tag is deleted from the vocabulary doesn't spuriously bake the deletion into the channel's stored delta.
|
|
1130
|
+
const predefinedEffectiveTags = getChannelEffectiveTags(predefinedBase);
|
|
1131
|
+
if (JSON.stringify(tags) !== JSON.stringify(predefinedEffectiveTags)) {
|
|
1132
|
+
delta.tags = (tags.length > 0) ? tags : null;
|
|
1133
|
+
hasChanges = true;
|
|
1134
|
+
}
|
|
1135
|
+
// Helper to check if form values (scalars + tags) match a given channel's properties. Tags use vocabulary-filtered comparison so variant matching works
|
|
1136
|
+
// correctly when predefined tags are deleted from the vocabulary.
|
|
1137
|
+
const formMatchesChannel = (ch) => Object.keys(formValues).every((field) => formValues[field] === channelValue(ch, field)) &&
|
|
1138
|
+
(JSON.stringify(tags) === JSON.stringify(getChannelEffectiveTags(ch)));
|
|
1070
1139
|
if (!hasChanges) {
|
|
1071
1140
|
// The submitted values match the predefined base exactly. If an override exists, this means the user edited their customizations away — treat it as an
|
|
1072
1141
|
// implicit revert by removing the override and returning the predefined row HTML.
|
|
@@ -1139,6 +1208,9 @@ export function setupChannelRoutes(app) {
|
|
|
1139
1208
|
if (channelNumberStr) {
|
|
1140
1209
|
channel.channelNumber = parseInt(channelNumberStr, 10);
|
|
1141
1210
|
}
|
|
1211
|
+
if (tags.length > 0) {
|
|
1212
|
+
channel.tags = tags;
|
|
1213
|
+
}
|
|
1142
1214
|
// Only store hdhrEnabled when explicitly disabled. Absent = true (included in HDHR lineup by default).
|
|
1143
1215
|
if (!hdhrEnabled) {
|
|
1144
1216
|
channel.hdhrEnabled = false;
|
|
@@ -1173,5 +1245,233 @@ export function setupChannelRoutes(app) {
|
|
|
1173
1245
|
res.status(500).json({ message: "Failed to save channel: " + formatError(error), success: false });
|
|
1174
1246
|
}
|
|
1175
1247
|
});
|
|
1248
|
+
// Tag Management Endpoints.
|
|
1249
|
+
// GET /config/tags - Returns the tag vocabulary and registry state. The active vocabulary is the computed merge of predefined tags (minus deleted) plus user
|
|
1250
|
+
// tags. The registry contains the raw user state for the tag management UI.
|
|
1251
|
+
app.get("/config/tags", (_req, res) => {
|
|
1252
|
+
res.json({
|
|
1253
|
+
active: getActiveTagVocabulary(),
|
|
1254
|
+
predefined: [...PREDEFINED_TAGS],
|
|
1255
|
+
registry: getTagRegistry(),
|
|
1256
|
+
success: true
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
// POST /config/tags - Create a new user tag. Validates the tag name (lowercase alphanumeric + hyphens, max 30 chars) and checks for duplicates against the
|
|
1260
|
+
// active vocabulary and deleted predefined tags (which should be restored, not re-created).
|
|
1261
|
+
app.post("/config/tags", async (req, res) => {
|
|
1262
|
+
try {
|
|
1263
|
+
const body = req.body;
|
|
1264
|
+
const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
|
|
1265
|
+
if (tag.length === 0) {
|
|
1266
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (tag.length > 30) {
|
|
1270
|
+
res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(tag)) {
|
|
1274
|
+
res.status(400).json({ error: "Tag name must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", success: false });
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
// Check if the tag already exists in the active vocabulary.
|
|
1278
|
+
const vocabulary = new Set(getActiveTagVocabulary());
|
|
1279
|
+
if (vocabulary.has(tag)) {
|
|
1280
|
+
res.status(409).json({ error: "Tag '" + tag + "' already exists.", success: false });
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
// Check if the tag is a deleted predefined tag — the user should restore it instead of creating a duplicate.
|
|
1284
|
+
const registry = getTagRegistry();
|
|
1285
|
+
if (registry.deletedTags.includes(tag)) {
|
|
1286
|
+
res.status(409).json({ error: "Tag '" + tag + "' is a deleted predefined tag. Use restore instead of creating a new one.", success: false });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
// Add the tag to the user registry and persist. No channels are affected (new tag has no assignments yet).
|
|
1290
|
+
registry.tags.push(tag);
|
|
1291
|
+
setTagRegistry(registry);
|
|
1292
|
+
await saveTagRegistry();
|
|
1293
|
+
LOG.info("Created tag: %s.", tag);
|
|
1294
|
+
res.json({
|
|
1295
|
+
active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1296
|
+
registry: getTagRegistry(), success: true
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
LOG.error("Failed to create tag: %s.", formatError(error));
|
|
1301
|
+
res.status(500).json({ error: "Failed to create tag: " + formatError(error), success: false });
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
// DELETE /config/tags/:tag - Delete a tag from the vocabulary and cascade to all channel assignments. For predefined tags, the tag is added to deletedTags. For
|
|
1305
|
+
// user-created tags, the tag is removed from the user registry. In both cases, the tag is stripped from every channel that has it — predefined channels via delta
|
|
1306
|
+
// override, user channels via direct modification. This ensures re-creating a tag with the same name starts fresh with no ghost assignments.
|
|
1307
|
+
app.delete("/config/tags/:tag", async (req, res) => {
|
|
1308
|
+
try {
|
|
1309
|
+
const tag = req.params.tag?.toLowerCase() ?? "";
|
|
1310
|
+
if (tag.length === 0) {
|
|
1311
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const registry = getTagRegistry();
|
|
1315
|
+
const isPredefined = PREDEFINED_TAGS.includes(tag);
|
|
1316
|
+
const isUserTag = registry.tags.includes(tag);
|
|
1317
|
+
if (!isPredefined && !isUserTag) {
|
|
1318
|
+
res.status(404).json({ error: "Tag '" + tag + "' not found.", success: false });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if (isPredefined) {
|
|
1322
|
+
// Already deleted — no-op, return current state without unnecessary I/O.
|
|
1323
|
+
if (registry.deletedTags.includes(tag)) {
|
|
1324
|
+
res.json({
|
|
1325
|
+
active: getActiveTagVocabulary(), filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1326
|
+
registry: getTagRegistry(), success: true
|
|
1327
|
+
});
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
registry.deletedTags.push(tag);
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
// Remove from user tags.
|
|
1334
|
+
registry.tags = registry.tags.filter((t) => t !== tag);
|
|
1335
|
+
}
|
|
1336
|
+
setTagRegistry(registry);
|
|
1337
|
+
// Cascade: strip the deleted tag from all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
|
|
1338
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.includes(tag) === true, (tags) => tags.filter((t) => t !== tag));
|
|
1339
|
+
if (error) {
|
|
1340
|
+
res.status(400).json({ error, success: false });
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
LOG.info("Deleted tag '%s' from vocabulary and %d channel assignments.", tag, affectedKeys.length);
|
|
1344
|
+
const profiles = getProfiles();
|
|
1345
|
+
res.json({
|
|
1346
|
+
active: getActiveTagVocabulary(),
|
|
1347
|
+
filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1348
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1349
|
+
registry: getTagRegistry(),
|
|
1350
|
+
success: true
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
LOG.error("Failed to delete tag: %s.", formatError(error));
|
|
1355
|
+
res.status(500).json({ error: "Failed to delete tag: " + formatError(error), success: false });
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
// POST /config/tags/restore - Restore a previously deleted predefined tag. Removes it from deletedTags so it reappears in the active vocabulary, then
|
|
1359
|
+
// cascade-restores the tag on predefined channels whose definition includes it. This reverses the cascade delete — predefined channels go back to their default
|
|
1360
|
+
// tag assignments. User channels are not affected (their tag assignments were permanently removed during cascade delete with no source of truth to restore from).
|
|
1361
|
+
app.post("/config/tags/restore", async (req, res) => {
|
|
1362
|
+
try {
|
|
1363
|
+
const body = req.body;
|
|
1364
|
+
const tag = typeof body.tag === "string" ? body.tag.trim().toLowerCase() : "";
|
|
1365
|
+
if (tag.length === 0) {
|
|
1366
|
+
res.status(400).json({ error: "Tag name is required.", success: false });
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const registry = getTagRegistry();
|
|
1370
|
+
if (!registry.deletedTags.includes(tag)) {
|
|
1371
|
+
res.status(404).json({ error: "Tag '" + tag + "' is not a deleted predefined tag.", success: false });
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
registry.deletedTags = registry.deletedTags.filter((t) => t !== tag);
|
|
1375
|
+
setTagRegistry(registry);
|
|
1376
|
+
// Cascade-restore: add the tag back to predefined channels whose definition includes it but whose current resolved tags don't (stripped during cascade
|
|
1377
|
+
// delete). transformChannelTags handles loading, delta normalization, and persistence. The normalizer strips the tags delta when the result matches the
|
|
1378
|
+
// predefined definition, cleanly reverting the channel to its default state.
|
|
1379
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => {
|
|
1380
|
+
const predefined = getPredefinedChannel(entry.key);
|
|
1381
|
+
return (predefined?.tags?.includes(tag) === true) && (entry.channel.tags?.includes(tag) !== true);
|
|
1382
|
+
}, (tags) => [...tags, tag]);
|
|
1383
|
+
if (error) {
|
|
1384
|
+
res.status(400).json({ error, success: false });
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
LOG.info("Restored predefined tag '%s' on %d channels.", tag, affectedKeys.length);
|
|
1388
|
+
const profiles = getProfiles();
|
|
1389
|
+
res.json({
|
|
1390
|
+
active: getActiveTagVocabulary(),
|
|
1391
|
+
filterContent: generateTagFilterContent(), modalBody: generateTagManagerBody(),
|
|
1392
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1393
|
+
registry: getTagRegistry(),
|
|
1394
|
+
success: true
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
catch (error) {
|
|
1398
|
+
LOG.error("Failed to restore tag: %s.", formatError(error));
|
|
1399
|
+
res.status(500).json({ error: "Failed to restore tag: " + formatError(error), success: false });
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
// POST /config/tags/rename - Rename a tag atomically across the vocabulary and all channel assignments. For predefined tags, the old name is added to
|
|
1403
|
+
// deletedTags and the new name is added to user tags. For user tags, the old name is replaced with the new name. All channels with the old tag get the
|
|
1404
|
+
// new tag substituted in their stored data (predefined channels via delta, user channels via direct modification).
|
|
1405
|
+
app.post("/config/tags/rename", async (req, res) => {
|
|
1406
|
+
try {
|
|
1407
|
+
const body = req.body;
|
|
1408
|
+
const oldTag = typeof body.oldTag === "string" ? body.oldTag.trim().toLowerCase() : "";
|
|
1409
|
+
const newTag = typeof body.newTag === "string" ? body.newTag.trim().toLowerCase() : "";
|
|
1410
|
+
if (!oldTag || !newTag) {
|
|
1411
|
+
res.status(400).json({ error: "Both old and new tag names are required.", success: false });
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (oldTag === newTag) {
|
|
1415
|
+
res.status(400).json({ error: "New tag name must differ from the old name.", success: false });
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
if (!(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/).test(newTag)) {
|
|
1419
|
+
res.status(400).json({
|
|
1420
|
+
error: "Tag name must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens.", success: false
|
|
1421
|
+
});
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (newTag.length > 30) {
|
|
1425
|
+
res.status(400).json({ error: "Tag name must be 30 characters or less.", success: false });
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
// Validate old tag exists in the active vocabulary.
|
|
1429
|
+
const vocabulary = new Set(getActiveTagVocabulary());
|
|
1430
|
+
if (!vocabulary.has(oldTag)) {
|
|
1431
|
+
res.status(404).json({ error: "Tag '" + oldTag + "' not found.", success: false });
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
// Validate new tag doesn't already exist.
|
|
1435
|
+
if (vocabulary.has(newTag)) {
|
|
1436
|
+
res.status(409).json({ error: "Tag '" + newTag + "' already exists.", success: false });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
// Update the tag registry: replace old with new in the appropriate list.
|
|
1440
|
+
const registry = getTagRegistry();
|
|
1441
|
+
const oldIsPredefined = PREDEFINED_TAGS.includes(oldTag);
|
|
1442
|
+
if (oldIsPredefined) {
|
|
1443
|
+
// Predefined tag: "delete" the old (add to deletedTags) and create the new as a user tag.
|
|
1444
|
+
if (!registry.deletedTags.includes(oldTag)) {
|
|
1445
|
+
registry.deletedTags.push(oldTag);
|
|
1446
|
+
}
|
|
1447
|
+
registry.tags.push(newTag);
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
// User tag: replace in the user tags list.
|
|
1451
|
+
registry.tags = registry.tags.map((t) => (t === oldTag) ? newTag : t);
|
|
1452
|
+
}
|
|
1453
|
+
setTagRegistry(registry);
|
|
1454
|
+
// Cascade: substitute the old tag with the new tag across all channel assignments. transformChannelTags handles loading, delta normalization, and persistence.
|
|
1455
|
+
const { affectedKeys, error } = await transformChannelTags((entry) => entry.channel.tags?.includes(oldTag) === true, (tags) => tags.map((t) => (t === oldTag) ? newTag : t));
|
|
1456
|
+
if (error) {
|
|
1457
|
+
res.status(400).json({ error, success: false });
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
LOG.info("Renamed tag '%s' to '%s' across %d channels.", oldTag, newTag, affectedKeys.length);
|
|
1461
|
+
const profiles = getProfiles();
|
|
1462
|
+
res.json({
|
|
1463
|
+
active: getActiveTagVocabulary(),
|
|
1464
|
+
filterContent: generateTagFilterContent(),
|
|
1465
|
+
modalBody: generateTagManagerBody(),
|
|
1466
|
+
patch: (affectedKeys.length > 0) ? buildChannelTablePatch(affectedKeys, profiles) : undefined,
|
|
1467
|
+
registry: getTagRegistry(),
|
|
1468
|
+
success: true
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
LOG.error("Failed to rename tag: %s.", formatError(error));
|
|
1473
|
+
res.status(500).json({ error: "Failed to rename tag: " + formatError(error), success: false });
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1176
1476
|
}
|
|
1177
1477
|
//# sourceMappingURL=routes.js.map
|