diff --git a/css/serene-shell.css b/css/serene-shell.css index 03d1f632..0dbe5876 100644 --- a/css/serene-shell.css +++ b/css/serene-shell.css @@ -663,6 +663,241 @@ body[data-surface="popup"] .input-group { border-color: rgba(46, 213, 115, 0.68); } +.import-export-rule-list .import-export-row { + align-items: stretch; +} + +.import-export-rule-list .import-export-text { + display: grid; + flex: 1 1 auto; + gap: 0.58rem; + max-width: none; +} + +.import-export-rule-list .import-export-actions { + flex: 0 0 12.5rem; + min-width: 12.5rem; + align-items: stretch; +} + +.import-backup-format-strip { + display: flex; + flex-wrap: wrap; + gap: 0.42rem; + margin-top: 0.45rem; +} + +.import-backup-format-strip span { + display: inline-flex; + align-items: center; + min-height: 1.85rem; + padding: 0.32rem 0.62rem; + border: 1px solid rgba(74, 157, 127, 0.22); + border-radius: 999px; + background: rgba(74, 157, 127, 0.08); + color: #12643e; + font-size: 0.76rem; + font-weight: 820; + line-height: 1.1; +} + +.rule-list-sheet-example { + display: grid; + gap: 0; + max-width: 44rem; + margin-top: 0.25rem; + overflow: hidden; + border: 1px solid rgba(15, 23, 42, 0.09); + border-radius: 1rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 252, 247, 0.9)), + var(--ft-color-bg-panel); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.82), + 0 18px 30px -28px rgba(15, 23, 42, 0.22); +} + +.rule-list-sheet-example__bar, +.rule-list-sheet-example__formats, +.rule-list-sheet-example__examples { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.58rem 0.72rem; + color: var(--ft-color-text-secondary); + font-size: 0.78rem; + line-height: 1.25; +} + +.rule-list-sheet-example__bar { + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.62); + font-weight: 850; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.rule-list-sheet-example__formats { + border-top: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 249, 241, 0.72); +} + +.rule-list-sheet-example__examples { + display: grid; + grid-template-columns: 1fr; + align-items: stretch; + justify-content: stretch; + border-top: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.52); +} + +.rule-list-sheet-example__examples div { + display: grid; + gap: 0.28rem; + min-width: 0; + padding: 0.58rem 0.68rem; + border: 1px solid rgba(15, 23, 42, 0.07); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.72); +} + +.rule-list-sheet-example__examples b { + color: var(--ft-color-brand-primary); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rule-list-sheet-example__examples pre { + max-width: 100%; + max-height: 8.8rem; + margin: 0; + overflow: auto; + color: var(--ft-color-text-secondary); + white-space: pre; + font: 0.74rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.rule-list-sheet-example code { + padding: 0.04rem 0.22rem; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0.35rem; + background: rgba(255, 255, 255, 0.72); + color: var(--ft-color-text-primary); + font-size: 0.9em; +} + +.rule-list-sheet-example__grid { + display: grid; + min-width: 0; +} + +.rule-list-sheet-example__row { + display: grid; + grid-template-columns: minmax(5.5rem, 0.55fr) minmax(9rem, 1fr) minmax(8rem, 1fr); + min-width: 0; +} + +.rule-list-sheet-example__row + .rule-list-sheet-example__row { + border-top: 1px solid rgba(15, 23, 42, 0.06); +} + +.rule-list-sheet-example__row span { + min-width: 0; + padding: 0.48rem 0.65rem; + overflow: hidden; + color: var(--ft-color-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; + border-right: 1px solid rgba(15, 23, 42, 0.06); + font-size: 0.8rem; +} + +.rule-list-sheet-example__row span:last-child { + border-right: 0; +} + +.rule-list-sheet-example__row--head span { + background: rgba(171, 68, 56, 0.08); + color: var(--ft-color-brand-primary); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rule-list-target-picker { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; + max-width: 44rem; + margin: 0.1rem 0 0; + padding: 0; + border: 0; +} + +.rule-list-target-picker legend { + width: 100%; + margin: 0 0 0.06rem; + color: var(--ft-color-text-secondary); + font-size: 0.76rem; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rule-list-target-picker label { + position: relative; + min-width: 0; + cursor: pointer; +} + +.rule-list-target-picker input { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; +} + +.rule-list-target-picker span { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.35rem; + padding: 0.48rem 0.86rem; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 999px; + background: rgba(255, 255, 255, 0.76); + color: var(--ft-color-text-secondary); + font-size: 0.84rem; + font-weight: 780; + line-height: 1; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); + transition: + transform 0.22s cubic-bezier(0.32, 0.72, 0, 1), + background-color 0.22s cubic-bezier(0.32, 0.72, 0, 1), + border-color 0.22s cubic-bezier(0.32, 0.72, 0, 1), + color 0.22s cubic-bezier(0.32, 0.72, 0, 1); +} + +.rule-list-target-picker label:hover span, +.rule-list-target-picker input:focus-visible + span { + border-color: rgba(171, 68, 56, 0.28); + color: var(--ft-color-brand-primary); + transform: translateY(-1px); +} + +.rule-list-target-picker input:checked + span { + border-color: rgba(171, 68, 56, 0.36); + background: linear-gradient(180deg, #c66354 0%, #aa4739 100%); + color: #fffaf7; + box-shadow: 0 16px 28px -22px rgba(129, 47, 35, 0.54); +} + #ftExportV3EncryptedBtn.btn-secondary { background: linear-gradient(180deg, rgba(196, 150, 72, 0.2), rgba(176, 126, 52, 0.14)); color: #724c16; @@ -722,6 +957,81 @@ html[data-theme="dark"] .import-export-actions .btn-secondary.btn-import:hover { border-color: rgba(110, 234, 156, 0.46); } +html[data-theme="dark"] .rule-list-sheet-example { + border-color: rgba(255, 255, 255, 0.1); + background: + linear-gradient(180deg, rgba(18, 25, 34, 0.94), rgba(16, 22, 30, 0.92)), + var(--ft-color-bg-card); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 18px 30px -26px rgba(0, 0, 0, 0.42); +} + +html[data-theme="dark"] .import-backup-format-strip span { + border-color: rgba(74, 222, 128, 0.2); + background: rgba(22, 101, 52, 0.14); + color: #9af0be; +} + +html[data-theme="dark"] .rule-list-sheet-example__bar { + border-bottom-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); +} + +html[data-theme="dark"] .rule-list-sheet-example__formats { + border-top-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.035); +} + +html[data-theme="dark"] .rule-list-sheet-example__examples { + border-top-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.025); +} + +html[data-theme="dark"] .rule-list-sheet-example__examples div { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); +} + +html[data-theme="dark"] .rule-list-sheet-example__examples pre { + color: var(--ft-color-text-secondary-dark); +} + +html[data-theme="dark"] .rule-list-sheet-example code { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.05); + color: var(--ft-color-text-primary); +} + +html[data-theme="dark"] .rule-list-sheet-example__row + .rule-list-sheet-example__row, +html[data-theme="dark"] .rule-list-sheet-example__row span { + border-color: rgba(255, 255, 255, 0.07); +} + +html[data-theme="dark"] .rule-list-sheet-example__row--head span { + background: rgba(195, 90, 75, 0.15); + color: #f7c7bd; +} + +html[data-theme="dark"] .rule-list-target-picker span { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: var(--ft-color-text-secondary-dark); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +html[data-theme="dark"] .rule-list-target-picker label:hover span, +html[data-theme="dark"] .rule-list-target-picker input:focus-visible + span { + border-color: rgba(195, 90, 75, 0.34); + color: #f7c7bd; +} + +html[data-theme="dark"] .rule-list-target-picker input:checked + span { + border-color: rgba(195, 90, 75, 0.36); + background: linear-gradient(180deg, #c36557 0%, #a9483c 100%); + color: #fffaf7; +} + html[data-theme="dark"] #ftExportV3EncryptedBtn.btn-secondary { background: rgba(112, 82, 36, 0.28); color: #e7ca92; diff --git a/css/tab-view.css b/css/tab-view.css index c3ffbfb8..3f7759bf 100644 --- a/css/tab-view.css +++ b/css/tab-view.css @@ -842,9 +842,12 @@ html[data-theme="dark"] body::after { .ft-managed-command-center__row { min-height: 76px; display: grid; - grid-template-columns: minmax(220px, 0.95fr) minmax(230px, 1fr) minmax(260px, 1.2fr) minmax(220px, auto); + grid-template-columns: minmax(210px, 1.1fr) minmax(260px, 1.4fr) minmax(188px, auto); + grid-template-areas: + "profile status actions" + "details details actions"; gap: 12px; - align-items: center; + align-items: start; padding: 12px; border: 1px solid var(--ft-color-sem-neutral-border); border-radius: 8px; @@ -869,10 +872,11 @@ html[data-theme="dark"] body::after { } .ft-managed-command-center__profile { + grid-area: profile; display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; - align-items: center; + align-items: start; min-width: 0; } @@ -924,7 +928,9 @@ html[data-theme="dark"] body::after { } .ft-managed-command-center__details { + grid-area: details; display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 6px; min-width: 0; } @@ -951,6 +957,7 @@ html[data-theme="dark"] body::after { } .ft-managed-command-center__status { + grid-area: status; display: flex; flex-wrap: wrap; gap: 5px; @@ -998,6 +1005,7 @@ html[data-theme="dark"] body::after { } .ft-managed-command-center__actions { + grid-area: actions; display: flex; flex-wrap: wrap; justify-content: flex-end; @@ -1510,6 +1518,14 @@ body:not(.ft-managed-child-editor-view) .ft-managed-child-global { grid-template-columns: 1fr; } + .ft-managed-command-center__row { + grid-template-areas: + "profile" + "status" + "details" + "actions"; + } + .ft-managed-command-center__meta { justify-content: flex-start; text-align: left; @@ -2183,6 +2199,36 @@ html[data-theme="dark"] .card::after { line-height: 1.45; } +.managed-channel-list-modal__template { + display: grid; + gap: 0.55rem; + padding: 0.78rem; + border: 1px solid rgba(74, 157, 127, 0.22); + border-radius: 0.9rem; + background: rgba(74, 157, 127, 0.07); +} + +.managed-channel-list-modal__template-title { + color: var(--ft-color-text-primary); + font-size: 0.86rem; + font-weight: 800; +} + +.managed-channel-list-modal__template pre { + max-height: 132px; + margin: 0; + overflow: auto; + white-space: pre; + color: var(--ft-color-text-secondary); + font: 0.78rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.managed-channel-list-modal__template-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + .managed-channel-list-modal__help, .managed-channel-list-modal__error { padding: 0.68rem 0.78rem; @@ -2197,6 +2243,128 @@ html[data-theme="dark"] .card::after { background: rgba(74, 157, 127, 0.08); } +.managed-channel-list-modal__formats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; +} + +.managed-channel-list-modal__formats span { + display: grid; + gap: 0.16rem; + min-width: 0; + padding: 0.58rem 0.68rem; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0.8rem; + background: rgba(255, 255, 255, 0.72); + color: var(--ft-color-text-secondary); + font-size: 0.78rem; + line-height: 1.35; +} + +.managed-channel-list-modal__formats b { + color: var(--ft-color-brand-primary); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.managed-channel-list-modal__formats code { + overflow: visible; + color: var(--ft-color-text-primary); + overflow-wrap: anywhere; + white-space: normal; + font-size: 0.84em; +} + +.managed-channel-list-modal__preview { + display: grid; + gap: 0.58rem; + padding: 0.72rem 0.78rem; + border: 1px solid rgba(82, 128, 214, 0.22); + border-radius: 0.9rem; + background: rgba(82, 128, 214, 0.08); +} + +.managed-channel-list-modal__preview-title { + color: var(--ft-color-text-primary); + font-size: 0.86rem; + font-weight: 850; +} + +.managed-channel-list-modal__preview-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.managed-channel-list-modal__preview-stat { + display: grid; + gap: 0.14rem; + min-width: 0; + padding: 0.58rem 0.64rem; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.78); +} + +.managed-channel-list-modal__preview-stat strong { + color: var(--ft-color-text-primary); + font-size: 1.1rem; + line-height: 1; +} + +.managed-channel-list-modal__preview-stat span, +.managed-channel-list-modal__preview-note, +.managed-channel-list-modal__preview-empty { + color: var(--ft-color-text-secondary); + font-size: 0.86rem; + line-height: 1.38; +} + +.managed-channel-list-modal__sheet { + display: grid; + overflow: hidden; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0.82rem; + background: rgba(255, 255, 255, 0.7); +} + +.managed-channel-list-modal__sheet-row { + display: grid; + grid-template-columns: minmax(5.5rem, 0.55fr) minmax(11rem, 1.2fr) minmax(7rem, 0.8fr); + min-width: 0; +} + +.managed-channel-list-modal__sheet-row + .managed-channel-list-modal__sheet-row { + border-top: 1px solid rgba(15, 23, 42, 0.06); +} + +.managed-channel-list-modal__sheet-row span { + min-width: 0; + padding: 0.5rem 0.62rem; + overflow: hidden; + color: var(--ft-color-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; + border-right: 1px solid rgba(15, 23, 42, 0.06); + font-size: 0.8rem; +} + +.managed-channel-list-modal__sheet-row span:last-child { + border-right: 0; +} + +.managed-channel-list-modal__sheet-head span { + background: rgba(82, 128, 214, 0.1); + color: var(--ft-color-text-primary); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .managed-channel-list-modal__library { display: grid; gap: 0.65rem; @@ -2273,6 +2441,36 @@ html[data-theme="dark"] .card::after { background: rgba(22, 101, 52, 0.16); } +:root[data-theme="dark"] .managed-channel-list-modal__template { + border-color: rgba(74, 222, 128, 0.22); + background: rgba(22, 101, 52, 0.14); +} + +:root[data-theme="dark"] .managed-channel-list-modal__formats span, +:root[data-theme="dark"] .managed-channel-list-modal__sheet, +:root[data-theme="dark"] .managed-channel-list-modal__preview-stat { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.05); +} + +:root[data-theme="dark"] .managed-channel-list-modal__formats code { + color: var(--ft-color-text-primary); +} + +:root[data-theme="dark"] .managed-channel-list-modal__preview { + border-color: rgba(96, 165, 250, 0.22); + background: rgba(30, 64, 175, 0.15); +} + +:root[data-theme="dark"] .managed-channel-list-modal__sheet-row + .managed-channel-list-modal__sheet-row, +:root[data-theme="dark"] .managed-channel-list-modal__sheet-row span { + border-color: rgba(255, 255, 255, 0.07); +} + +:root[data-theme="dark"] .managed-channel-list-modal__sheet-head span { + background: rgba(96, 165, 250, 0.14); +} + :root[data-theme="dark"] .managed-channel-list-modal__library-item { border-color: var(--ft-color-sem-neutral-border); background: var(--ft-color-bg-card); @@ -2285,6 +2483,19 @@ html[data-theme="dark"] .card::after { } @media (max-width: 640px) { + .managed-channel-list-modal__formats, + .managed-channel-list-modal__preview-grid { + grid-template-columns: 1fr; + } + + .managed-channel-list-modal__sheet { + overflow-x: auto; + } + + .managed-channel-list-modal__sheet-row { + min-width: 34rem; + } + .managed-channel-list-modal__url-row { grid-template-columns: 1fr; } diff --git a/docs/audit/FILTERTUBE_LOCAL_NETWORK_MANAGED_PARENT_CONTROLS_PLAN_2026-06-03.md b/docs/audit/FILTERTUBE_LOCAL_NETWORK_MANAGED_PARENT_CONTROLS_PLAN_2026-06-03.md index 9beaf970..a1e9f30b 100644 --- a/docs/audit/FILTERTUBE_LOCAL_NETWORK_MANAGED_PARENT_CONTROLS_PLAN_2026-06-03.md +++ b/docs/audit/FILTERTUBE_LOCAL_NETWORK_MANAGED_PARENT_CONTROLS_PLAN_2026-06-03.md @@ -94,13 +94,18 @@ extension authority code. per-profile details column so profile names and next actions do not collapse under provider/status copy. - [x] Parent workflow strip: Family Controls now leads with `Choose profile`, - `Set rules and time`, and `Pair or send` so parents understand that local - control works first and verified-device delivery is only needed for another - device. + `Set guardrails`, and `Sync when needed` so parents understand that local + control works first and verified-device delivery is only needed when the same + protected profile must update another device. - [x] Family Controls row copy and feedback were simplified for parent use: - `Pair to sync` means remote device setup only, profile ownership reads as - `Parent: ...`, status chips have explanatory titles, and neutral detail cards - no longer look like warning/error states when nothing is wrong. + rows now say `Device sync: Not paired` for local-only profiles, saved + verified devices say `open both devices` when live P2P is needed, profile + ownership reads as `Parent: ...`, status chips have explanatory titles, and + neutral detail cards no longer look like warning/error states when nothing is + wrong. +- [x] Family Controls row layout now keeps profile/status/actions on the first + line and moves details underneath, so parent names, device state, list source, + and action buttons do not squeeze each other on desktop or mobile. - [x] Accounts & Sync now shows a protected-edit boundary when a parent is editing a protected profile: Family Controls remains the target surface for rules/time/history/send, generic device pairing remains parent-owned, and @@ -116,10 +121,11 @@ parent tool instead of a sync/debug console. and time, then pair/send only when another device needs the update. - [x] Optional mailbox/LAN provider rows are not shown as first-run required setup when no protected profile exists or when providers are not configured. -- [x] Imported channel lists are treated as parent-approved rule sources, not as +- [x] Imported rule lists are treated as parent-approved rule sources, not as transport authority or executable filter code. -- [x] List-derived channel rules preserve source metadata, source format, - source hash, last checked time, pause state, and Manual-vs-list separation. +- [x] List-derived channel and keyword rules preserve source metadata, source + format, source hash, last checked time, pause state, and Manual-vs-list + separation. - [x] Channels page exposes a source filter/dropdown so parents can view `Manual`, `Imported lists`, and individual managed lists without guessing where a channel entry came from. @@ -134,7 +140,7 @@ parent tool instead of a sync/debug console. - [ ] Managed action history clearly answers who changed a rule, whether it was manual or list-derived, and whether it was sent to a verified device, without exposing raw policy JSON or sensitive rule payloads to protected users. -- [ ] Downstream app UI contract documents the same channel-list source filter, +- [ ] Downstream app UI contract documents the same rule-list source filter, Kids list selection, and source badges so mobile/tablet surfaces do not fork the parent mental model. - [x] Command center can send signed active managed-policy updates to currently @@ -152,15 +158,21 @@ parent tool instead of a sync/debug console. while offline. - [x] Runtime Main/Kids route gate, background-owned time-budget accounting, and protected timeout overlay exist for active protected profiles. -- [x] Managed channel filter-list imports and parent-triggered URL subscriptions +- [x] Managed rule-list imports and parent-triggered URL subscriptions are now part of the managed parent/caregiver goal. Issue 62 asks for - content-blocker-style channel lists that can be imported, enabled, disabled, - and synced instead of forcing parents to add channels one at a time. The + content-blocker-style lists that can be imported, enabled, disabled, + and synced instead of forcing parents to add channels or keywords one at a time. The extension-owned manual/check/refresh/pause/remove path is present. This is a parent/caregiver rule-source feature, not an untrusted URL authority path. The parent-facing flow stays simple: import or check a list, preview channels, - choose protected profiles, apply, then send to verified devices when delivery - is ready. Silent scheduled refresh remains deferred. + keywords, and skipped rows, choose protected profiles, apply, then send to + verified devices when delivery is ready. Silent scheduled refresh remains + deferred. + - [x] First CSV rule-list slice: parents can use a visible CSV template with + `channel_id,keyword,notes`, paste or load CSV, preview explicit channels and + explicit keywords separately, apply them to Main/Kids/both under the target + profile current Blocklist/Whitelist mode, and send the changed profile + policy through the existing managed-policy JSON path. - [x] First local import slice: parent/account profiles can paste or choose a text file, preview valid channel identifiers, apply the list to selected protected profiles on Main/Kids/both, write protected redacted history, and @@ -214,13 +226,13 @@ parent tool instead of a sync/debug console. list revision they imported or refreshed, while the URL/list still has no policy authority by itself. - [x] First structured-list compatibility slice: parents can paste or choose - a simple JSON channel list (`channels`, `items`, `entries`, - `blockedChannels`, `channelIds`, or `handles`) and the entries still - normalize through the same preview, parent re-auth, materialized channel - rows, list metadata, and verified-device delivery path as text lists. - Materialized rows now also preserve compact source-format metadata so apps - can distinguish text rows from JSON sources without treating either format - as policy authority. + a simple JSON rule list (`channels`, `items`, `entries`, `blockedChannels`, + `channelIds`, `handles`, and explicit `keywords`) and the entries still + normalize through the same preview, parent re-auth, materialized rule rows, + list metadata, and verified-device delivery path as text lists. Materialized + rows now also preserve compact source-format metadata so apps can + distinguish text rows from JSON/CSV sources without treating any format as + policy authority. - [x] First subscription-check slice: parent/account profiles can check URL-backed lists from the `Lists` action. Changed source hashes refresh materialized channel rows after parent re-auth; unchanged source hashes only @@ -964,7 +976,7 @@ the current extension dashboard. Parent/account-authorized profile manager views now also show a command-center overview for protected profiles, viewing spaces, time limits, sync status, and protected history. Command-center row buttons are delegated action intents - for existing gated Edit Rules, History, Time Limit, and Send Update paths, + for existing gated Rules, History, Time Limit, and Send paths, plus a selected-profile rule editor handoff, local selected-profile bulk time-limit and viewing-space actions, and selected-profile signed-policy sends. Child/protected views do not receive detailed managed status text or @@ -982,7 +994,7 @@ the current extension dashboard. when both sides are available. Unconfigured mailbox and local-network provider setup is hidden from the normal command center so parents are not asked to bring infrastructure. If a provider is already configured, the UI labels it as - Later Updates or Same-Network and uses plain parent/user language while audit + Offline Pickup or Same-Network and uses plain parent/user language while audit docs retain the trusted-link, target-profile, scope, revision, hash, and signature authority proof. - **Acceptance Criteria**: diff --git a/docs/audit/FILTERTUBE_MANAGED_IMPORT_LIST_FORMAT_2026-06-18.md b/docs/audit/FILTERTUBE_MANAGED_IMPORT_LIST_FORMAT_2026-06-18.md new file mode 100644 index 00000000..ff088ad8 --- /dev/null +++ b/docs/audit/FILTERTUBE_MANAGED_IMPORT_LIST_FORMAT_2026-06-18.md @@ -0,0 +1,324 @@ +# FilterTube Managed Import List Format + +Date: 2026-06-18 +Scope: parent/caregiver-managed protected profiles, local profile rules, Nanah live sync handoff, and future app parity. + +## Goal + +Keep imports simple enough for parents and caregivers: + +1. The file provides rule values. +2. FilterTube previews what it understood. +3. The parent chooses protected profiles. +4. The parent chooses Main YouTube, YouTube Kids, or both. +5. FilterTube applies the rules locally after parent/account unlock. +6. FilterTube can then send the changed profile policy to verified devices. + +The file must not decide parent authority, target child profile, PIN behavior, sync delivery, list mode, or Main/Kids access. Those decisions stay in the UI. + +## User-Facing Name + +Use `Import List` in the UI. + +Avoid making parents choose between many importer types. The importer can accept: + +- channel lists +- keyword lists +- mixed CSV rule lists +- URL-backed lists + +The preview should explain the result in plain counts: + +```text +48 channels +12 keywords +3 rows skipped +Applies to: Main + Kids +Profiles: Asha, Kabir +``` + +## Recommended CSV Format + +CSV is for explicit channel identifiers and explicit keywords. It is not semantic inference. + +Recommended header: + +```csv +channel_id,keyword,notes +UCxxxxxxxxxxxxxxxxxxxxxx,,block this channel +@SomeChannel,,handle is accepted +https://www.youtube.com/@AnotherChannel,,URL is accepted +,spider,hide this topic keyword +,brainrot,hide this keyword +``` + +Rules: + +- `channel_id` can contain: + - `UC...` channel ID + - `@handle` + - `/channel/UC...` + - `/c/name` + - `/user/name` + - YouTube channel URL +- `keyword` can contain a plain keyword or phrase. +- `notes` is ignored by runtime and exists only so list authors can explain rows. +- A row may contain a channel, a keyword, or both. +- Empty rows and comment rows are skipped. +- Name-only channel rows are skipped for safety. + +Accepted channel column aliases: + +```text +channel +channel_id +channelId +channel_url +youtube_url +url +handle +uc_id +``` + +Accepted keyword column aliases: + +```text +keyword +keywords +term +terms +phrase +phrases +``` + +## Alternate CSV Format + +For list maintainers who prefer one value column: + +```csv +type,value,notes +channel,UCxxxxxxxxxxxxxxxxxxxxxx,channel id +channel,@SomeChannel,handle +keyword,spider,topic keyword +keyword,brainrot,topic keyword +``` + +This is readable, but the UI should still recommend `channel_id,keyword,notes` because it is easier for spreadsheet users. + +## Plain Text Format + +Plain text remains channel-first for safety. + +```text +# title: Family channel block list +# version: 2026.06.18 +UCxxxxxxxxxxxxxxxxxxxxxx +@SomeChannel +https://www.youtube.com/@AnotherChannel +``` + +Do not treat arbitrary plain text rows as keywords. A note like `bad thumbnails` should not silently become a keyword rule. + +## JSON Format + +JSON can support both channels and keywords. + +```json +{ + "title": "Family safety starter list", + "version": "2026.06.18", + "homepage": "https://example.com/filtertube-list", + "channels": [ + "UCxxxxxxxxxxxxxxxxxxxxxx", + "@SomeChannel", + "https://www.youtube.com/@AnotherChannel" + ], + "keywords": [ + "spider", + "brainrot" + ] +} +``` + +Simple arrays remain channel lists for backward compatibility: + +```json +[ + "UCxxxxxxxxxxxxxxxxxxxxxx", + "@SomeChannel" +] +``` + +## Not In The File + +These must not be accepted from the import file: + +- protected profile id +- parent profile id +- PIN or password +- Nanah trusted-device id +- sync destination +- list mode change +- Main/Kids access mode +- daily time limit +- allow/deny authority +- remote command + +Reason: a downloaded list should never become authority. It is only a source of suggested rules. + +## How It Applies + +Parent UI owns the final choices: + +```text +Import List + -> preview channels/keywords/skipped rows + -> choose protected profiles + -> choose Main / Kids / Both + -> parent/account unlock + -> apply rows to each target profile's current mode + -> optionally send update to verified device +``` + +### Parent/Caregiver User Flow + +```mermaid +flowchart TD + A["Parent opens Accounts and Sync"] --> B["Choose protected profile or selected profiles"] + B --> C["Open Lists"] + C --> D["Import List"] + D --> E["Paste, choose file, load HTTPS URL, or download CSV template"] + E --> F["FilterTube previews channels, keywords, skipped rows"] + F --> G{"Parent accepts preview?"} + G -- "No" --> H["Cancel with no profile changes"] + G -- "Yes" --> I["Choose Main, Kids, or Both"] + I --> J["Parent/account unlock"] + J --> K["Apply explicit rules to selected protected profiles"] + K --> L{"Verified device path available?"} + L -- "No" --> M["Keep local profile policy ready"] + L -- "Yes" --> N["Offer Send Update"] + N --> O["Send managed-policy JSON through existing verified-device path"] +``` + +### Rule Import Behavior Flow + +```mermaid +flowchart TD + A["Input text/file/URL"] --> B{"Looks like JSON?"} + B -- "Yes" --> C["Parse channels and keywords arrays"] + B -- "No" --> D{"CSV headers found?"} + D -- "Yes" --> E["Parse channel_id and keyword columns or type/value rows"] + D -- "No" --> F["Plain text channel-only parser"] + C --> G["Normalize explicit channel identifiers"] + E --> G + F --> G + C --> H["Normalize explicit keywords"] + E --> H + G --> I["Deduplicate against target profile surface"] + H --> I + I --> J["Decorate rows with managedListId, source hash, source label"] + J --> K["Save rows under current Blocklist or Whitelist mode"] +``` + +### Verified-Device Sync Flow + +Imported list rows do not create a second sync protocol. After rules are applied +to a protected profile, FilterTube sends the changed policy through the existing +managed-policy JSON path when the parent chooses to send now. + +```mermaid +sequenceDiagram + participant Parent as Parent profile + participant Runtime as FilterTube extension runtime + participant Nanah as Nanah verified path + participant Child as Protected device/profile + + Parent->>Runtime: Apply imported rule list after unlock + Runtime->>Runtime: Update protected profile channels/keywords + Runtime->>Runtime: Record protected action history + Runtime->>Parent: Offer Send Update when verified path exists + Parent->>Runtime: Confirm send + Runtime->>Nanah: Send signed managed-policy JSON envelope + Nanah->>Child: Deliver to verified target profile path + Child->>Child: Verify trusted link, target profile, scope, revision, hash + Child->>Child: Apply only if newer and trusted +``` + +Mode behavior: + +- If the target surface is in blocklist mode, imported channels go to block channels and imported keywords go to block keywords. +- If the target surface is in whitelist mode, imported channels go to whitelist channels and imported keywords go to whitelist keywords. +- The import does not switch modes. + +Pause/resume/remove behavior: + +- Rows from the same imported list share `managedListId`. +- Pause disables list-derived channel and keyword rows without deleting manual rules. +- Resume re-enables them. +- Remove deletes only rows with that `managedListId`. +- URL refresh replaces only rows from that list. + +## Semantic ML Boundary + +This CSV format is deterministic. It does not infer related channels or related keywords. + +Later semantic ML can build a separate `suggested rules` layer: + +```text +seed term -> suggested keywords/channels -> parent review -> explicit rules +``` + +That future layer should still end at the same explicit rule model before it affects a child/protected profile. + +## UI Simplification + +Use one modal with four clear areas: + +```text +1. List source + Paste, choose file, or load public HTTPS URL. + +2. Preview + Channels found, keywords found, rows skipped. + +3. Apply to + Main, Kids, or Both. + +4. Finish + Apply locally, then optionally Send to verified devices. +``` + +Avoid showing mailbox/LAN/provider wording inside the import flow. Import is local first; delivery is the next optional step. + +## 2026-06-18 UX Completion Note + +The first parser slice was not complete from a user-flow standpoint because CSV import was only reachable through protected-profile list actions and Help text. A second pass briefly placed separate import affordances inside Main Filters and YouTube Kids channel management, but that split the mental model: Filters/Kids pages are rule-editing surfaces, while external files and backup/list imports belong in Settings. + +Current completion rule: + +- Primary entry point: Settings -> Import / Export -> Rule list imports. +- Target choice: Main YouTube, YouTube Kids, or Both. This works for the active profile or the protected profile currently being edited by a parent/account profile. +- Main/Kids rule pages remain the place to review, edit, pause, resume, and remove the imported rows after import. +- The Settings card shows a sheet-like structure preview instead of dense prose: `type`, `value`, `notes`, plus supported CSV/JSON shapes. +- The modal shows supported formats, CSV template, file/URL/paste inputs, live preview counts, skipped row counts, a spreadsheet-like parsed-row preview, and the final Apply confirmation. +- Rule-list JSON is intentionally narrower than a full FilterTube backup JSON. It may add channels and keywords only; it does not change profile structure, PINs, trusted devices, viewing spaces, or sync targets. +- The Settings card and import modal expose both CSV and JSON rule-list templates. The CSV template is the spreadsheet path; the JSON template is the lightweight rule-list shape, not the full FilterTube backup/export structure. +- Import backup remains the full restore/migration lane for FilterTube backup JSON and legacy BlockTube export JSON. +- Help text should stay short and point to the UI path; this audit file owns the detailed format contract. + +Supported source shapes in this slice: + +- CSV: `channel_id,keyword,notes`, `channel,keyword,notes`, or typed rows such as `type,value,notes`. +- Text: bare rows are treated as channel IDs/handles/custom URLs/URLs. Explicit typed rows are also accepted: `channel: @SomeChannel`, `channel: UC...`, `channel: c/Name`, and `keyword: brainrot`. +- Simple JSON: `channels` and/or `keywords` arrays. +- BlockTube-style JSON: `filterData.channelId`, `filterData.channelName`, and `filterData.title` arrays are read as rule-list channels/keywords. +- Raw HTTPS source URL: public CSV, text, or JSON fetched into the same preview before apply. + +Not shipped in this CSV PR: + +- Built-in global/public list catalog. +- Automatic subscriptions to third-party lists. +- Parent/community moderation workflow for shared "good" or "bad" channel lists. +- Silent application across profiles or devices. + +Those are compatible with this foundation, but they need a separate catalog and governance design: source URL, maintainer, scope, last checked, content hash, update policy, enable/disable state, user review, and per-profile Main/Kids target selection. diff --git a/html/tab-view.html b/html/tab-view.html index 6c192798..5a4ab14a 100644 --- a/html/tab-view.html +++ b/html/tab-view.html @@ -460,11 +460,16 @@

Import / Export

- Merge a saved FilterTube export or compatible JSON.
+ Merge a full FilterTube backup or compatible backup JSON.
- Existing entries stay put; matching channels/keywords are merged + Use this for complete settings restore or legacy BlockTube export migration. For previewed channel/keyword-only lists, use Rule list imports below. +
+ FilterTube backup JSON + BlockTube export JSON + Full restore or migration +
Details @@ -480,6 +485,94 @@

Import / Export

+
+
Rule list imports
+
+
+ + Import channel and keyword lists into the current profile.
+
+ + Use CSV, text, raw HTTPS lists, or rule-list JSON. FilterTube previews parsed rows before anything changes. + +
+
+ CSV sheet example + .csv .txt .json +
+
+
+ type + value + notes +
+
+ channel + @SomeChannel + handle, ID, or URL +
+
+ keyword + brainrot + word or phrase +
+
+
+ CSV headers: channel_id,keyword,notes + TXT: channel: and keyword: rows + JSON: channels + keywords arrays + BlockTube JSON: filterData +
+
+
+ TXT example +
channel: @SomeChannel
+channel: UCxxxxxxxxxxxxxxxxxxxxxx
+channel: c/ChannelURL
+channel: https://www.youtube.com/@AnotherChannel
+keyword: brainrot
+
+
+ JSON example +
{
+  "channels": ["@SomeChannel", "UCxxxxxxxxxxxxxxxxxxxxxx", "c/ChannelURL"],
+  "keywords": ["brainrot", "scary thumbnail"]
+}
+
+
+
+
+ Apply imported rules to + + + +
+
+
+ + + + +
+
+
@@ -1048,8 +1141,8 @@

Filters

Add a channel by UC... or @handle or /c/Name or /user/Name.
Channels blocked from the YouTube 3-dot menu will appear here automatically.
-
Channel list imports
-
Parent/account profiles can use Lists in Family Controls to view, import, pause, resume, check, refresh, or remove channel lists for protected profiles. URL lists load into the same preview box before anything changes. FilterTube previews valid channel identifiers, skips name-only rows for safety, applies the list to selected protected profiles, and can send changed rules to verified devices. Paused lists stay saved but stop contributing channel rules. URL-backed lists show source title/version when a list provides it, last-checked and hash metadata, show when they need refresh, can be checked one at a time, only when stale, or together from the Lists menu. If the source hash is unchanged, FilterTube updates checked metadata without replacing channel rows.
+
Rule list imports
+
Open Settings > Import / Export > Rule list imports. Choose Main YouTube, YouTube Kids, or Both, then preview a CSV, text, raw HTTPS list, or rule-list JSON before applying it. Imported rows are then reviewed and edited from the normal Main/Kids rule pages.
Filter All Content (per-channel)
@@ -1304,7 +1397,7 @@

Nanah Sync

  • A live Nanah session is not meant to survive a page refresh, browser restart, or tab close. Refresh ends the live pairing session.
  • Trusted links are saved locally so you do not lose the remembered relationship, but today you still start a fresh live session when reconnecting.
  • The trusted-device card now has a Start New Session shortcut that starts the next fresh session with the saved role/policy defaults.
  • -
  • Saved trust means a faster next session. If a later-update service is available, a protected profile can check for signed parent updates when it opens without adding YouTube page background work.
  • +
  • Saved trust means a faster next live P2P session. If an advanced offline-pickup service is separately configured, a protected profile can check for signed parent updates when it opens without adding YouTube page background work.
  • If you try to reuse the same pairing code on the same active device session, FilterTube now blocks that locally instead of silently burning the code.
  • If no fixed target is saved on the managed replica link, non-full sync still writes into the receiver's current active profile at receive time.
  • If a managed replica link is saved with Always this local profile, future managed updates land in that pinned local profile even when some other profile is active on the receiving device.
  • @@ -1351,7 +1444,7 @@

    Nanah Sync

    What the relay does
    -
    Nanah’s relay is only there to help both devices meet and exchange connection setup data. It is not meant to be a place where FilterTube reads your synced rules. After the secure handshake, your settings are meant to move directly device-to-device. The underlying project is open here: github.com/varshneydevansh/nanah.
    +
    Nanah’s relay is only there to help both devices meet and exchange connection setup data. It is not a mailbox and it is not meant to read or store synced rules. In the normal parent flow, open both devices, pair, verify the phrase, and send directly device-to-device. The underlying project is open here: github.com/varshneydevansh/nanah.
    @@ -1474,9 +1567,9 @@

    Profiles, PINs, and Child Profiles

    -
    Import a channel list for protected profiles
    +
    Import a rule list for protected profiles
    - In Accounts & Sync, use Lists on a protected profile or selected protected profiles. View existing imported lists first, or choose Import List, paste a list, load a public HTTPS list URL, or choose a text file, preview the valid channels, choose Main YouTube, YouTube Kids, or both, then apply after parent/account unlock. Family Controls shows which protected profiles have imported lists, when they were last checked, and a compact source hash. URL-backed lists also show when they need refresh. Use Lists again to pause or resume a list, check one URL-backed list, check only stale URL-backed lists, check all URL-backed lists in one parent-approved pass, or remove an imported list; manual rules stay untouched. Unchanged source hashes only update checked metadata. + From the parent/account profile, open a child row and choose Edit Rules. Then use Settings > Import / Export > Rule list imports and choose Main, Kids, or Both. Imported files only add channel/keyword rules; they cannot change PINs, trusted devices, viewing spaces, or sync targets. After import, use the protected profile row to send the updated policy to a verified device.
    @@ -1488,7 +1581,7 @@

    Profiles, PINs, and Child Profiles

    Remote updates in plain words
    - Use live P2P when both devices are open. Use Later Updates only when a protected device should receive parent changes after it opens later. Use Same-Network only when you run a trusted local gateway. None of these options lets a protected profile edit its own rules. + Normal parent control is live P2P: open parent and protected devices, pair, verify the phrase, then send the selected profile update. Saved trust remembers the verified parent-child relationship and target profile for the next live session. Offline pickup and same-network gateways are advanced optional delivery tools only if you run a compatible service; they are not required for ordinary parent control and they never let a protected profile edit its own rules.
    diff --git a/js/managed_parent_command_center.js b/js/managed_parent_command_center.js index b21d8d35..cc4291a9 100644 --- a/js/managed_parent_command_center.js +++ b/js/managed_parent_command_center.js @@ -72,7 +72,7 @@ const intents = [ { action: 'edit_rules', - label: 'Edit Rules', + label: 'Rules', profileId: targetId, scope: 'main_kids', authority: 'delegated_runtime_gate', @@ -96,7 +96,7 @@ }, { action: 'send_managed_policy', - label: 'Send Update', + label: 'Send', profileId: targetId, scope: 'active', authority: 'managed_policy_provider_delivery', @@ -104,7 +104,7 @@ }, { action: timeLimitActive ? 'change_time_limit' : 'set_time_limit', - label: timeLimitActive ? 'Change Limit' : 'Set Limit', + label: timeLimitActive ? 'Change Time' : 'Set Time', profileId: targetId, scope: 'time_limits', authority: 'delegated_runtime_gate', @@ -184,7 +184,7 @@ id: listId, name: typeof item.managedListName === 'string' && item.managedListName.trim() ? item.managedListName.trim() - : 'Imported channel list', + : 'Imported rule list', rowCount: 0, activeRowCount: 0, pausedRowCount: 0, @@ -227,8 +227,12 @@ const kids = safeObject(profile?.kids); addRows(main.channels, 'Main'); addRows(main.whitelistChannels, 'Main'); + addRows(main.keywords, 'Main'); + addRows(main.whitelistKeywords, 'Main'); addRows(kids.blockedChannels, 'Kids'); addRows(kids.whitelistChannels, 'Kids'); + addRows(kids.blockedKeywords, 'Kids'); + addRows(kids.whitelistKeywords, 'Kids'); const now = Date.now(); const items = Array.from(lists.values()).map((item) => ({ id: item.id, @@ -389,7 +393,7 @@ if (readyCount <= 0) { return { key: 'provider_needed', - label: 'Provider setup needed', + label: 'Open both devices', tone: 'warning' }; } @@ -403,14 +407,14 @@ if (item.syncLocalNetworkReady === true) { return { key: 'local_network', - label: 'LAN provider ready', + label: 'Same-network ready', tone: 'success' }; } if (item.syncMailboxReady === true) { return { key: 'mailbox', - label: 'Mailbox later', + label: 'Offline pickup ready', tone: 'success' }; } @@ -440,12 +444,12 @@ return 'Local control works now. Pair only when updates need to reach another device.'; } if (readyCount <= 0) { - return `${targetCount} verified ${targetCount === 1 ? 'device is' : 'devices are'} paired; open both devices for live P2P or enable a later delivery provider.`; + return `${targetCount} verified ${targetCount === 1 ? 'device is' : 'devices are'} paired; open parent and protected devices together for live P2P.`; } const paths = []; if (item.syncLiveReady === true) paths.push('live P2P'); - if (item.syncLocalNetworkReady === true) paths.push('LAN'); - if (item.syncMailboxReady === true) paths.push('mailbox later'); + if (item.syncLocalNetworkReady === true) paths.push('same-network'); + if (item.syncMailboxReady === true) paths.push('offline pickup'); return paths.length ? `${targetCount} verified ${targetCount === 1 ? 'device' : 'devices'} via ${paths.join(' + ')}.` : `${readyCount} verified ${readyCount === 1 ? 'queue is' : 'queues are'} ready.`; @@ -842,9 +846,9 @@ strip.className = 'ft-managed-command-center__strip'; [ { label: 'Managed profiles', value: summary.profileCount, tone: 'neutral', title: 'Profiles this parent/account can manage.', always: true }, - { label: 'Ready', value: summary.syncReadyProfileCount, tone: summary.syncReadyProfileCount ? 'success' : 'neutral', title: 'Profiles with a verified delivery path available now or through a configured provider.', always: true }, - { label: 'Lists active', value: summary.managedChannelListProfileCount, tone: 'success', title: 'Protected profiles with imported channel-list rules.' }, - { label: 'Needs setup', value: summary.noDeviceProfileCount + summary.syncRepairProfileCount + summary.syncStaleProfileCount, tone: 'warning', title: 'Profiles that need a verified device, refreshed trust, or re-pairing before remote updates.' }, + { label: 'Ready to send', value: summary.syncReadyProfileCount, tone: summary.syncReadyProfileCount ? 'success' : 'neutral', title: 'Profiles with a verified delivery path available now.', always: true }, + { label: 'Lists active', value: summary.managedChannelListProfileCount, tone: 'success', title: 'Protected profiles with imported rule lists.' }, + { label: 'Needs device', value: summary.noDeviceProfileCount + summary.syncRepairProfileCount + summary.syncStaleProfileCount, tone: 'warning', title: 'Profiles that need a verified device, refreshed trust, or re-pairing before remote updates.' }, { label: 'Requests', value: summary.pendingExtraTimeRequestCount, tone: 'warning', title: 'Protected profiles asking for more YouTube time.' }, { label: 'Conflicts', value: summary.remoteConflictCount, tone: 'danger', title: 'Rejected or conflicting remote-policy history rows that need parent review.' } ].filter(item => item.always || (Number(item.value) || 0) > 0).forEach((item) => { @@ -869,23 +873,23 @@ label: 'Choose profile', detail: `${summary.profileCount} protected ${summary.profileCount === 1 ? 'profile' : 'profiles'} available`, tone: 'neutral', - title: 'Start by choosing the protected child/user profile you want to manage.' + title: 'Choose the child, family member, or other protected profile you want to manage.' }, { step: '2', - label: 'Set rules and time', + label: 'Set guardrails', detail: summary.managedChannelListProfileCount > 0 ? 'Rules, lists, access, and time are ready to review' - : 'Use Edit Rules, Lists, Set Limit, and Main/Kids controls', + : 'Use Rules, Lists, Set Time, and Main/Kids controls', tone: summary.managedChannelListProfileCount > 0 || summary.limitedCount > 0 ? 'success' : 'neutral', title: 'These actions change the selected protected profile after parent/account approval.' }, { step: '3', - label: 'Pair or send', + label: 'Sync when needed', detail: summary.syncReadyProfileCount > 0 ? `${summary.syncReadyProfileCount} ${summary.syncReadyProfileCount === 1 ? 'profile has' : 'profiles have'} a verified delivery path` - : 'Pair a verified device only when updates must reach another device', + : 'Pair only when this profile also lives on another device', tone: summary.syncReadyProfileCount > 0 ? 'success' : 'warning', title: 'Local control works without remote delivery. Pairing is only needed for another device.' } @@ -1198,7 +1202,7 @@ [ { label: item.viewingAccess, tone: 'neutral', title: 'Allowed YouTube space for this protected profile.' }, { label: item.timeLimit, tone: item.timeLimited ? 'warning' : 'neutral', title: 'Daily YouTube time for this protected profile.' }, - item.managedChannelListLabel ? { label: item.managedChannelListLabel, tone: 'success', title: item.managedChannelListDetail || 'Imported channel lists attached to this profile.' } : null, + item.managedChannelListLabel ? { label: item.managedChannelListLabel, tone: 'success', title: item.managedChannelListDetail || 'Imported rule lists attached to this profile.' } : null, { label: syncState.label, tone: syncState.tone, title: item.deliveryPathDetail || 'Device delivery status.' }, item.remoteScopeCount ? { label: item.syncLabel, tone: 'success', title: 'Latest accepted managed policy revision.' } : null, item.pendingExtraTimeRequestLabel ? { label: item.pendingExtraTimeRequestLabel, tone: 'warning', title: item.pendingExtraTimeRequestDetail || 'This profile asked for more time.' } : null, @@ -1217,10 +1221,10 @@ detailsWrap.className = 'ft-managed-command-center__details'; [ hasVerifiedDevice - ? { label: 'Delivery', value: item.deliveryPreview?.label || 'Send when ready', note: item.deliveryPathDetail } - : { label: 'Next step', value: 'Pair only for another device', note: 'This profile can still be controlled locally. Use live P2P when parent and protected devices are both open.' }, + ? { label: 'Device sync', value: item.deliveryPreview?.label || 'Send when ready', note: item.deliveryPathDetail } + : { label: 'Device sync', value: 'Not paired', note: 'Local rules and time limits work here. Pair only when this profile must also update another device.' }, item.managedChannelListDetail ? { label: 'Lists', value: item.managedChannelListDetail } : null, - hasVerifiedDevice ? { label: 'Device', value: item.syncTargetLabel } : null, + hasVerifiedDevice ? { label: 'Verified device', value: item.syncTargetLabel } : null, item.pendingExtraTimeRequestDetail ? { label: 'Request', value: item.pendingExtraTimeRequestDetail } : null, item.remoteConflictCount > 0 ? { label: 'Conflict', value: `${item.remoteConflictCount} needs review` } : null ].filter(Boolean).forEach((detail) => { diff --git a/js/tab-view.js b/js/tab-view.js index 892ec9c2..3d68b46f 100644 --- a/js/tab-view.js +++ b/js/tab-view.js @@ -2832,6 +2832,10 @@ document.addEventListener('DOMContentLoaded', async () => { const ftImportV3Btn = document.getElementById('ftImportV3Btn'); const ftImportV3File = document.getElementById('ftImportV3File'); const ftImportSyncDeviceBtn = document.getElementById('ftImportSyncDeviceBtn'); + const ftImportRuleListBtn = document.getElementById('ftImportRuleListBtn'); + const ftDownloadRuleListTemplateBtn = document.getElementById('ftDownloadRuleListTemplateBtn'); + const ftDownloadRuleListJsonTemplateBtn = document.getElementById('ftDownloadRuleListJsonTemplateBtn'); + const ftRuleListFormatsBtn = document.getElementById('ftRuleListFormatsBtn'); const ftProfilesManager = document.getElementById('ftProfilesManager'); const managedChildSyncBoundary = document.getElementById('managedChildSyncBoundary'); @@ -3083,12 +3087,12 @@ document.addEventListener('DOMContentLoaded', async () => { 'policy.viewing_space.update': 'Viewing space policy changed', 'policy.time_limit.update': 'Time limit policy changed', 'policy.time_limit.request_extra': 'Extra time requested', - 'policy.channel_list.import': 'Channel list imported', - 'policy.channel_list.remove': 'Channel list removed', - 'policy.channel_list.check': 'Channel list checked', - 'policy.channel_list.refresh': 'Channel list refreshed', - 'policy.channel_list.pause': 'Channel list paused', - 'policy.channel_list.resume': 'Channel list resumed', + 'policy.channel_list.import': 'Rule list imported', + 'policy.channel_list.remove': 'Rule list removed', + 'policy.channel_list.check': 'Rule list checked', + 'policy.channel_list.refresh': 'Rule list refreshed', + 'policy.channel_list.pause': 'Rule list paused', + 'policy.channel_list.resume': 'Rule list resumed', 'policy.sync_policy.update': 'Sync policy changed', 'trust_link.create': 'Trusted link created', 'trust_link.revoke': 'Trusted link removed', @@ -7249,9 +7253,12 @@ document.addEventListener('DOMContentLoaded', async () => { const normalized = normalizeString(value).toLowerCase(); const allowed = new Set([ 'plain_text_rows', + 'typed_text_rows', 'csv_like_text_rows', + 'csv_channel_keyword_rows', 'simple_json_array', 'simple_json_object_channels', + 'blocktube_json_rules', 'public_https_text_or_json_url' ]); return allowed.has(normalized) ? normalized : ''; @@ -7260,12 +7267,152 @@ document.addEventListener('DOMContentLoaded', async () => { function formatManagedChannelListSourceFormat(value) { const normalized = normalizeManagedChannelListSourceFormat(value); if (normalized === 'simple_json_array' || normalized === 'simple_json_object_channels') return 'JSON'; + if (normalized === 'blocktube_json_rules') return 'BlockTube JSON'; if (normalized === 'public_https_text_or_json_url') return 'URL source'; + if (normalized === 'csv_channel_keyword_rows') return 'CSV rules'; + if (normalized === 'typed_text_rows') return 'TXT rules'; if (normalized === 'csv_like_text_rows') return 'CSV-like text'; if (normalized === 'plain_text_rows') return 'text list'; return ''; } + function splitManagedChannelListCsvRow(line) { + const cells = []; + let current = ''; + let quoted = false; + const raw = String(line || ''); + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]; + if (ch === '"') { + if (quoted && raw[i + 1] === '"') { + current += '"'; + i += 1; + } else { + quoted = !quoted; + } + continue; + } + if (ch === ',' && !quoted) { + cells.push(current.trim()); + current = ''; + continue; + } + current += ch; + } + cells.push(current.trim()); + return cells; + } + + function normalizeManagedChannelListCsvHeader(value) { + return normalizeString(value).toLowerCase().replace(/[^a-z0-9]+/g, ''); + } + + function parseManagedChannelKeywordCsv(text, { listName = '' } = {}) { + const lines = normalizeString(text).split(/\r?\n/); + const dataLines = lines.filter((line) => { + const trimmed = normalizeString(line); + return trimmed && !/^(#|!|\/\/|\[)/.test(trimmed); + }); + if (!dataLines.length || !dataLines[0].includes(',')) return null; + + const headers = splitManagedChannelListCsvRow(dataLines[0]).map(normalizeManagedChannelListCsvHeader); + const channelIndexes = []; + const keywordIndexes = []; + const typeIndexes = []; + const valueIndexes = []; + headers.forEach((header, index) => { + if (['channel', 'channelid', 'channelurl', 'youtubeurl', 'url', 'handle', 'ucid'].includes(header)) { + channelIndexes.push(index); + } + if (['keyword', 'keywords', 'term', 'terms', 'phrase', 'phrases'].includes(header)) { + keywordIndexes.push(index); + } + if (['type', 'kind', 'ruletype'].includes(header)) { + typeIndexes.push(index); + } + if (['value', 'rule', 'entry'].includes(header)) { + valueIndexes.push(index); + } + }); + const hasTypedValueRows = typeIndexes.length > 0 && valueIndexes.length > 0; + if (!channelIndexes.length && !keywordIndexes.length && !hasTypedValueRows) return null; + + const listId = buildManagedChannelListId(listName || 'Imported list', text); + const seenChannels = new Set(); + const seenKeywords = new Set(); + const channels = []; + const keywords = []; + let skippedCount = 0; + const addChannelToken = (token) => { + const extracted = extractManagedChannelListToken(token); + if (!extracted) return false; + const channel = normalizeProfileChannel(extracted); + if (!channel) return false; + const key = managedChannelEntryKey(channel); + if (!key || seenChannels.has(key)) return false; + seenChannels.add(key); + channels.push({ + ...channel, + source: 'managed_channel_list', + managedListId: listId, + managedListName: normalizeString(listName) || 'Imported rule list' + }); + return true; + }; + const addKeywordToken = (token) => { + const keyword = normalizeProfileKeyword(token, { comments: true }); + if (!keyword) return false; + const key = normalizeString(keyword.word).toLowerCase(); + if (!key || seenKeywords.has(key)) return false; + seenKeywords.add(key); + keywords.push({ + ...keyword, + source: 'managed_channel_list', + managedListId: listId, + managedListName: normalizeString(listName) || 'Imported rule list' + }); + return true; + }; + + dataLines.slice(1).forEach((line) => { + const cells = splitManagedChannelListCsvRow(line); + let rowAccepted = false; + channelIndexes.forEach((index) => { + if (addChannelToken(cells[index])) rowAccepted = true; + }); + keywordIndexes.forEach((index) => { + if (addKeywordToken(cells[index])) rowAccepted = true; + }); + if (hasTypedValueRows) { + typeIndexes.forEach((typeIndex) => { + const type = normalizeManagedChannelListCsvHeader(cells[typeIndex]); + valueIndexes.forEach((valueIndex) => { + const value = cells[valueIndex]; + if (['channel', 'channels', 'channelid', 'handle', 'url'].includes(type)) { + if (addChannelToken(value)) rowAccepted = true; + return; + } + if (['keyword', 'keywords', 'term', 'terms', 'phrase', 'phrases'].includes(type)) { + if (addKeywordToken(value)) rowAccepted = true; + } + }); + }); + } + if (!rowAccepted) skippedCount += 1; + }); + + return { + listId, + contentHash: buildManagedChannelListContentHash(text), + sourceFormat: 'csv_channel_keyword_rows', + sourceMetadata: parseManagedChannelListSourceMetadata(text), + channels, + keywords, + skippedCount, + totalLineCount: dataLines.length > 0 ? dataLines.length - 1 : 0 + }; + } + function parseManagedChannelListSourceMetadata(rawText) { const result = {}; normalizeString(rawText).split(/\r?\n/).slice(0, 120).forEach((line) => { @@ -7304,6 +7451,13 @@ document.addEventListener('DOMContentLoaded', async () => { function getManagedChannelListJsonItems(root) { if (Array.isArray(root)) return root; const item = safeObject(root); + const filterData = safeObject(item.filterData); + if (Array.isArray(filterData.channelId) || Array.isArray(filterData.channelName)) { + return [ + ...safeArray(filterData.channelId), + ...safeArray(filterData.channelName) + ]; + } const candidateKeys = [ 'channels', 'items', @@ -7318,6 +7472,49 @@ document.addEventListener('DOMContentLoaded', async () => { return []; } + function getManagedChannelListJsonKeywordItems(root) { + if (Array.isArray(root)) return []; + const item = safeObject(root); + const filterData = safeObject(item.filterData); + if (Array.isArray(filterData.title)) return filterData.title; + const candidateKeys = [ + 'keywords', + 'blockedKeywords', + 'whitelistKeywords', + 'terms', + 'phrases' + ]; + for (const key of candidateKeys) { + if (Array.isArray(item[key])) return item[key]; + } + return []; + } + + function getManagedChannelListJsonEntryType(entry) { + const item = safeObject(entry); + return normalizeString(item.type || item.kind || item.ruleType || item.rule_type).toLowerCase(); + } + + function normalizeManagedChannelListJsonChannel(entry) { + const token = extractManagedChannelListJsonToken(entry); + if (token) return normalizeProfileChannel(token); + const item = safeObject(entry); + const candidateName = typeof entry === 'string' || typeof entry === 'number' + ? normalizeString(entry) + : normalizeString(item.name || item.channelName || item.title || item.label); + if (!candidateName || /^(#|!|\/\/|\[)/.test(candidateName)) return null; + return { + name: candidateName, + id: '', + handle: null, + customUrl: null, + originalInput: candidateName, + source: 'managed_channel_list', + filterAll: false, + addedAt: Date.now() + }; + } + function extractManagedChannelListJsonToken(entry) { if (typeof entry === 'string' || typeof entry === 'number') { return extractManagedChannelListToken(String(entry)); @@ -7345,22 +7542,62 @@ document.addEventListener('DOMContentLoaded', async () => { return ''; } + function extractManagedChannelListJsonKeyword(entry) { + if (typeof entry === 'string' || typeof entry === 'number') { + return normalizeString(entry); + } + const item = safeObject(entry); + const candidateKeys = [ + 'keyword', + 'word', + 'term', + 'phrase', + 'value', + 'text' + ]; + for (const key of candidateKeys) { + const token = normalizeString(item[key]); + if (token) return token; + } + return ''; + } + function parseManagedChannelListJson(text, { listName = '' } = {}) { const parsedRoot = JSON.parse(text); const listId = buildManagedChannelListId(listName || 'Imported list', text); const items = getManagedChannelListJsonItems(parsedRoot); - const sourceFormat = Array.isArray(parsedRoot) ? 'simple_json_array' : 'simple_json_object_channels'; + const keywordItems = getManagedChannelListJsonKeywordItems(parsedRoot); + const sourceFormat = safeObject(parsedRoot).filterData + ? 'blocktube_json_rules' + : (Array.isArray(parsedRoot) ? 'simple_json_array' : 'simple_json_object_channels'); const seen = new Set(); + const seenKeywords = new Set(); const channels = []; + const keywords = []; let skippedCount = 0; + const addKeyword = (entry) => { + const token = extractManagedChannelListJsonKeyword(entry); + const keyword = normalizeProfileKeyword(token, { comments: true }); + if (!keyword) return false; + const key = normalizeString(keyword.word).toLowerCase(); + if (!key || seenKeywords.has(key)) return true; + seenKeywords.add(key); + keywords.push({ + ...keyword, + source: 'managed_channel_list', + managedListId: listId, + managedListName: normalizeString(listName) || 'Imported rule list' + }); + return true; + }; items.forEach((entry) => { - const token = extractManagedChannelListJsonToken(entry); - if (!token) { - skippedCount += 1; + const entryType = getManagedChannelListJsonEntryType(entry); + if (['keyword', 'keywords', 'term', 'phrase'].includes(entryType)) { + if (!addKeyword(entry)) skippedCount += 1; return; } - const channel = normalizeProfileChannel(token); + const channel = normalizeManagedChannelListJsonChannel(entry); if (!channel) { skippedCount += 1; return; @@ -7372,9 +7609,12 @@ document.addEventListener('DOMContentLoaded', async () => { ...channel, source: 'managed_channel_list', managedListId: listId, - managedListName: normalizeString(listName) || 'Imported channel list' + managedListName: normalizeString(listName) || 'Imported rule list' }); }); + keywordItems.forEach((entry) => { + if (!addKeyword(entry)) skippedCount += 1; + }); return { listId, @@ -7382,8 +7622,9 @@ document.addEventListener('DOMContentLoaded', async () => { sourceFormat, sourceMetadata: normalizeManagedChannelListJsonMetadata(parsedRoot), channels, + keywords, skippedCount, - totalLineCount: items.length + totalLineCount: items.length + keywordItems.length }; } @@ -7392,47 +7633,89 @@ document.addEventListener('DOMContentLoaded', async () => { if (/^\s*[\[{]/.test(text)) { try { const parsedJson = parseManagedChannelListJson(text, { listName }); - if (parsedJson.channels.length || parsedJson.totalLineCount > 0) { + if (parsedJson.channels.length || parsedJson.keywords.length || parsedJson.totalLineCount > 0) { return parsedJson; } } catch (e) { // Fall through to line parsing so pasted mixed text still works. } } + const parsedCsv = parseManagedChannelKeywordCsv(text, { listName }); + if (parsedCsv && (parsedCsv.channels.length || parsedCsv.keywords.length || parsedCsv.totalLineCount > 0)) { + return parsedCsv; + } const listId = buildManagedChannelListId(listName || 'Imported list', text); const lines = text.split(/\r?\n/); const seen = new Set(); + const seenKeywords = new Set(); const channels = []; + const keywords = []; let skippedCount = 0; + let typedRowCount = 0; + + const addTextKeyword = (token) => { + const keyword = normalizeProfileKeyword(token, { comments: true }); + if (!keyword) return false; + const key = normalizeString(keyword.word).toLowerCase(); + if (!key || seenKeywords.has(key)) return true; + seenKeywords.add(key); + keywords.push({ + ...keyword, + source: 'managed_channel_list', + managedListId: listId, + managedListName: normalizeString(listName) || 'Imported rule list' + }); + return true; + }; - lines.forEach((line) => { - const token = extractManagedChannelListToken(line); - if (!token) { - if (normalizeString(line) && !/^(#|!|\/\/|\[)/.test(normalizeString(line))) skippedCount += 1; - return; - } - const channel = normalizeProfileChannel(token); - if (!channel) { - skippedCount += 1; - return; - } + const addTextChannel = (token) => { + const extracted = extractManagedChannelListToken(token); + if (!extracted) return false; + const channel = normalizeProfileChannel(extracted); + if (!channel) return false; const key = managedChannelEntryKey(channel); - if (!key || seen.has(key)) return; + if (!key || seen.has(key)) return true; seen.add(key); channels.push({ ...channel, source: 'managed_channel_list', managedListId: listId, - managedListName: normalizeString(listName) || 'Imported channel list' + managedListName: normalizeString(listName) || 'Imported rule list' }); + return true; + }; + + lines.forEach((line) => { + const trimmed = normalizeString(line); + if (!trimmed || /^(#|!|\/\/|\[)/.test(trimmed)) return; + const typedMatch = trimmed.match(/^([A-Za-z][A-Za-z0-9 _-]{1,24})\s*[:=]\s*(.+)$/); + if (typedMatch) { + const type = normalizeManagedChannelListCsvHeader(typedMatch[1]); + const value = normalizeString(typedMatch[2]); + if (['keyword', 'keywords', 'term', 'terms', 'phrase', 'phrases'].includes(type)) { + typedRowCount += 1; + if (!addTextKeyword(value)) skippedCount += 1; + return; + } + if (['channel', 'channels', 'channelid', 'handle', 'url', 'ucid', 'customurl'].includes(type)) { + typedRowCount += 1; + if (!addTextChannel(value)) skippedCount += 1; + return; + } + } + if (!addTextChannel(trimmed)) { + skippedCount += 1; + return; + } }); return { listId, contentHash: buildManagedChannelListContentHash(text), - sourceFormat: 'plain_text_rows', + sourceFormat: typedRowCount > 0 ? 'typed_text_rows' : 'plain_text_rows', sourceMetadata: parseManagedChannelListSourceMetadata(text), channels, + keywords, skippedCount, totalLineCount: lines.filter(line => normalizeString(line)).length }; @@ -7447,7 +7730,160 @@ document.addEventListener('DOMContentLoaded', async () => { }); } - async function showManagedChannelListImportModal({ selectedCount = 1 } = {}) { + const MANAGED_RULE_LIST_CSV_TEMPLATE = [ + 'channel_id,keyword,notes', + '# @SomeChannel,,channel handle example; remove # and replace before import', + '# UCxxxxxxxxxxxxxxxxxxxxxx,,channel id example; remove # and replace before import', + '# https://www.youtube.com/@AnotherChannel,,channel URL example; remove # and replace before import', + '# ,spider,keyword or phrase example; remove # and replace before import', + '# ,brainrot,keyword or phrase example; remove # and replace before import' + ].join('\n'); + + const MANAGED_RULE_LIST_JSON_TEMPLATE = JSON.stringify({ + title: 'Family rule list', + description: 'Channels and keywords only. This is not a full FilterTube backup.', + channels: [ + '@SomeChannel', + 'UCxxxxxxxxxxxxxxxxxxxxxx', + 'c/ChannelURL', + 'https://www.youtube.com/@AnotherChannel' + ], + keywords: [ + 'brainrot', + 'scary thumbnail' + ] + }, null, 2); + + function countManagedRuleListRows(parsed = {}) { + return { + channels: safeArray(parsed.channels).length, + keywords: safeArray(parsed.keywords).length, + total: safeArray(parsed.channels).length + safeArray(parsed.keywords).length + }; + } + + function escapeManagedRuleListPreviewCell(value) { + return normalizeString(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function formatManagedRuleListChannelValue(channel = {}) { + return normalizeString(channel.handle || channel.id || channel.customUrl || channel.name || channel.originalInput); + } + + function buildManagedRuleListPreviewRows(parsed = {}) { + const source = formatManagedChannelListSourceFormat(parsed.sourceFormat) || 'Imported list'; + const channels = safeArray(parsed.channels).slice(0, 5).map((channel) => ({ + type: 'Channel', + value: formatManagedRuleListChannelValue(channel), + source + })); + const keywords = safeArray(parsed.keywords).slice(0, 5).map((keyword) => ({ + type: 'Keyword', + value: normalizeString(keyword?.word || keyword), + source + })); + return [...channels, ...keywords].filter(row => row.value).slice(0, 8); + } + + function renderManagedRuleListPreviewSheet(parsed = {}) { + const rows = buildManagedRuleListPreviewRows(parsed); + if (!rows.length) { + return '
    No readable rows yet. Use channel_id for channels and keyword for words or phrases.
    '; + } + return ` +
    +
    + Type + Value + Source +
    + ${rows.map(row => ` +
    + ${escapeManagedRuleListPreviewCell(row.type)} + ${escapeManagedRuleListPreviewCell(row.value)} + ${escapeManagedRuleListPreviewCell(row.source)} +
    + `).join('')} +
    + `; + } + + function buildManagedRuleListEmptyPreviewNote(text, parsed = {}) { + const trimmed = normalizeString(text); + const skipped = Number(parsed.skippedCount) || 0; + if (/^[\[{]/.test(trimmed)) { + return skipped + ? 'JSON was readable, but no supported channel or keyword rows were found. Use channels and keywords arrays, or BlockTube filterData arrays.' + : 'JSON was readable, but it did not contain supported channels or keywords arrays.'; + } + if (trimmed.includes(',')) { + return skipped + ? `${skipped} ${pluralize(skipped, 'row')} skipped. CSV should include channel_id and/or keyword columns, or type,value rows.` + : 'CSV needs channel_id and/or keyword headers, or type,value rows.'; + } + return skipped + ? `${skipped} ${pluralize(skipped, 'row')} skipped. TXT accepts channel: rows for YouTube channel IDs, handles, custom URLs, or URLs, and keyword: rows for keywords.` + : 'TXT accepts channel: rows for channel IDs, handles, custom URLs, or URLs, and keyword: rows for keywords.'; + } + + function buildManagedRuleListParseErrorMessage(text) { + const trimmed = normalizeString(text); + if (/^[\[{]/.test(trimmed)) { + return 'JSON could not be parsed. Check braces, brackets, commas, and quoted keys, then preview again.'; + } + if (trimmed.includes(',')) { + return 'CSV could not be read as rules. Use channel_id, keyword, notes headers, or type, value, notes rows.'; + } + return 'FilterTube could not read supported rules from this text. TXT can use channel: @SomeChannel and keyword: brainrot rows; bare rows are treated as channels.'; + } + + function formatManagedRuleListCount(counts = {}) { + const channelCount = Number(counts.channels) || 0; + const keywordCount = Number(counts.keywords) || 0; + const parts = []; + if (channelCount) parts.push(`${channelCount} ${pluralize(channelCount, 'channel')}`); + if (keywordCount) parts.push(`${keywordCount} ${pluralize(keywordCount, 'keyword')}`); + return parts.length ? parts.join(' + ') : '0 rules'; + } + + function downloadManagedRuleListCsvTemplate() { + try { + const blob = new Blob([MANAGED_RULE_LIST_CSV_TEMPLATE], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'filtertube-rule-list-template.csv'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (error) { + UIComponents.showToast('Unable to download CSV template', 'error'); + } + } + + function downloadManagedRuleListJsonTemplate() { + try { + const blob = new Blob([MANAGED_RULE_LIST_JSON_TEMPLATE], { type: 'application/json;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'filtertube-rule-list-template.json'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (error) { + UIComponents.showToast('Unable to download JSON template', 'error'); + } + } + + async function showManagedChannelListImportModal({ selectedCount = 1, targetLabel = '' } = {}) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'ft-modal-overlay'; @@ -7459,7 +7895,7 @@ document.addEventListener('DOMContentLoaded', async () => { header.className = 'card-header'; const titleEl = document.createElement('h3'); titleEl.className = 'ft-modal-title'; - titleEl.textContent = 'Import Channel List'; + titleEl.textContent = 'Import List'; header.appendChild(titleEl); const body = document.createElement('div'); @@ -7467,11 +7903,23 @@ document.addEventListener('DOMContentLoaded', async () => { const intro = document.createElement('div'); intro.className = 'import-export-hint'; - intro.textContent = selectedCount > 1 - ? `Paste or choose a text/JSON channel list, then apply it to ${selectedCount} selected protected profiles.` - : 'Paste or choose a text/JSON channel list, then apply it to this protected profile.'; + const targetCopy = normalizeString(targetLabel) || (selectedCount > 1 + ? `${selectedCount} selected protected profiles` + : 'this profile'); + intro.textContent = `Import a channel/keyword list, review the parsed rows, then apply it to ${targetCopy}. Rule lists never change profiles, PINs, trusted devices, or viewing access.`; body.appendChild(intro); + const formatGuide = document.createElement('div'); + formatGuide.className = 'managed-channel-list-modal__formats'; + formatGuide.innerHTML = ` + CSVchannel_id,keyword,notes + TXTchannel: @SomeChannelkeyword: brainrot + JSON{"channels":["@SomeChannel","c/ChannelURL"],"keywords":["brainrot"]} + BlockTubefilterData.channelId + filterData.title + Public listraw HTTPS CSV, TXT, or JSON + `; + body.appendChild(formatGuide); + const nameGroup = document.createElement('label'); nameGroup.className = 'managed-channel-list-modal__field'; const nameLabel = document.createElement('span'); @@ -7480,7 +7928,7 @@ document.addEventListener('DOMContentLoaded', async () => { nameInput.className = 'text-input'; nameInput.type = 'text'; nameInput.placeholder = 'Family block list'; - nameInput.value = 'Imported channel list'; + nameInput.value = 'Imported rule list'; nameGroup.append(nameLabel, nameInput); body.appendChild(nameGroup); @@ -7493,7 +7941,7 @@ document.addEventListener('DOMContentLoaded', async () => { const urlInput = document.createElement('input'); urlInput.className = 'text-input'; urlInput.type = 'url'; - urlInput.placeholder = 'https://raw.githubusercontent.com/user/list/main/channels.txt'; + urlInput.placeholder = 'https://raw.githubusercontent.com/user/list/main/filtertube-rules.csv'; const loadUrlBtn = document.createElement('button'); loadUrlBtn.className = 'btn-secondary'; loadUrlBtn.type = 'button'; @@ -7506,7 +7954,7 @@ document.addEventListener('DOMContentLoaded', async () => { const fileGroup = document.createElement('label'); fileGroup.className = 'managed-channel-list-modal__field'; const fileLabel = document.createElement('span'); - fileLabel.textContent = 'Optional text file'; + fileLabel.textContent = 'Optional list file'; const fileInput = document.createElement('input'); fileInput.className = 'managed-channel-list-modal__file'; fileInput.type = 'file'; @@ -7517,18 +7965,56 @@ document.addEventListener('DOMContentLoaded', async () => { const listGroup = document.createElement('label'); listGroup.className = 'managed-channel-list-modal__field'; const listLabel = document.createElement('span'); - listLabel.textContent = 'Channels'; + listLabel.textContent = 'Channels and keywords'; const textArea = document.createElement('textarea'); textArea.className = 'text-input managed-channel-list-modal__textarea'; - textArea.placeholder = 'Paste one channel per line: @handle, UC..., /channel/UC..., /c/name, /user/name, or a YouTube channel URL'; + textArea.placeholder = MANAGED_RULE_LIST_CSV_TEMPLATE; listGroup.append(listLabel, textArea); body.appendChild(listGroup); + const templateBox = document.createElement('div'); + templateBox.className = 'managed-channel-list-modal__template'; + const templateTitle = document.createElement('div'); + templateTitle.className = 'managed-channel-list-modal__template-title'; + templateTitle.textContent = 'CSV template'; + const templateText = document.createElement('pre'); + templateText.textContent = MANAGED_RULE_LIST_CSV_TEMPLATE; + const templateActions = document.createElement('div'); + templateActions.className = 'managed-channel-list-modal__template-actions'; + const useTemplateBtn = document.createElement('button'); + useTemplateBtn.className = 'btn-secondary'; + useTemplateBtn.type = 'button'; + useTemplateBtn.textContent = 'Use CSV'; + useTemplateBtn.title = 'Puts the CSV template into the preview box so you can edit it.'; + const useJsonTemplateBtn = document.createElement('button'); + useJsonTemplateBtn.className = 'btn-secondary'; + useJsonTemplateBtn.type = 'button'; + useJsonTemplateBtn.textContent = 'Use JSON'; + useJsonTemplateBtn.title = 'Puts the JSON rule-list template into the preview box so you can edit it.'; + const downloadTemplateBtn = document.createElement('button'); + downloadTemplateBtn.className = 'btn-secondary'; + downloadTemplateBtn.type = 'button'; + downloadTemplateBtn.textContent = 'Download CSV'; + downloadTemplateBtn.title = 'Downloads a CSV template for spreadsheet editing.'; + const downloadJsonTemplateBtn = document.createElement('button'); + downloadJsonTemplateBtn.className = 'btn-secondary'; + downloadJsonTemplateBtn.type = 'button'; + downloadJsonTemplateBtn.textContent = 'Download JSON'; + downloadJsonTemplateBtn.title = 'Downloads a JSON rule-list template.'; + templateActions.append(useTemplateBtn, useJsonTemplateBtn, downloadTemplateBtn, downloadJsonTemplateBtn); + templateBox.append(templateTitle, templateText, templateActions); + body.appendChild(templateBox); + const help = document.createElement('div'); help.className = 'managed-channel-list-modal__help'; - help.textContent = 'Text rows or JSON channels need @handle, UC id, /c/name, /user/name, or a YouTube channel URL. Name-only rows are skipped for safety. A URL is only a source for previewed channel rules; it does not get control over protected profiles.'; + help.textContent = 'TXT bare rows stay channel-only; use keyword: for TXT keywords. CSV and supported JSON can add channels and keywords. FilterTube shows parsed rows before any profile is changed.'; body.appendChild(help); + const previewEl = document.createElement('div'); + previewEl.className = 'managed-channel-list-modal__preview'; + previewEl.setAttribute('aria-live', 'polite'); + body.appendChild(previewEl); + const errorEl = document.createElement('div'); errorEl.className = 'managed-channel-list-modal__error'; errorEl.hidden = true; @@ -7558,6 +8044,38 @@ document.addEventListener('DOMContentLoaded', async () => { errorEl.textContent = message; errorEl.hidden = !message; }; + + const renderPreview = () => { + const text = normalizeString(textArea.value); + if (!text) { + previewEl.innerHTML = ` +
    Preview
    +
    Paste CSV, load a URL, choose a file, or use the template. The preview will show Type, Value, and Source before anything is applied.
    + `; + return; + } + const name = normalizeString(nameInput.value) || 'Imported rule list'; + try { + const parsed = parseManagedChannelListText(text, { listName: name }); + const counts = countManagedRuleListRows(parsed); + previewEl.innerHTML = ` +
    Preview
    +
    +
    ${counts.channels}Channels
    +
    ${counts.keywords}Keywords
    +
    ${parsed.skippedCount || 0}Skipped
    +
    + ${renderManagedRuleListPreviewSheet(parsed)} +
    ${counts.total ? 'Ready to review. Confirming will apply only these rule values.' : buildManagedRuleListEmptyPreviewNote(text, parsed)}
    + `; + } catch (error) { + previewEl.innerHTML = ` +
    Preview
    +
    ${escapeManagedRuleListPreviewCell(buildManagedRuleListParseErrorMessage(text))}
    + `; + } + }; + let loadedSourceUrl = ''; let loadedSourceLabel = ''; let loadedSourceText = ''; @@ -7575,10 +8093,10 @@ document.addEventListener('DOMContentLoaded', async () => { cancelBtn.addEventListener('click', () => closeWith(null)); okBtn.addEventListener('click', () => { - const name = normalizeString(nameInput.value) || 'Imported channel list'; + const name = normalizeString(nameInput.value) || 'Imported rule list'; const text = normalizeString(textArea.value); if (!text) { - setError('Paste channels or choose a text file first.'); + setError('Paste rules or choose a list file first.'); return; } const loadedSourceMatches = loadedSourceUrl && text === loadedSourceText; @@ -7589,6 +8107,26 @@ document.addEventListener('DOMContentLoaded', async () => { sourceUrl: loadedSourceMatches ? loadedSourceUrl : '' }); }); + useTemplateBtn.addEventListener('click', () => { + textArea.value = MANAGED_RULE_LIST_CSV_TEMPLATE; + setLoadedSource(); + setError(''); + renderPreview(); + textArea.focus(); + }); + useJsonTemplateBtn.addEventListener('click', () => { + textArea.value = MANAGED_RULE_LIST_JSON_TEMPLATE; + setLoadedSource(); + setError(''); + renderPreview(); + textArea.focus(); + }); + downloadTemplateBtn.addEventListener('click', () => { + downloadManagedRuleListCsvTemplate(); + }); + downloadJsonTemplateBtn.addEventListener('click', () => { + downloadManagedRuleListJsonTemplate(); + }); loadUrlBtn.addEventListener('click', async (event) => { event.preventDefault(); const rawUrl = normalizeString(urlInput.value); @@ -7610,10 +8148,11 @@ document.addEventListener('DOMContentLoaded', async () => { text: loaded.text }); urlInput.value = loaded.url; - if (!normalizeString(nameInput.value) || nameInput.value === 'Imported channel list') { - nameInput.value = loaded.sourceLabel || 'Imported channel list'; + if (!normalizeString(nameInput.value) || nameInput.value === 'Imported rule list') { + nameInput.value = loaded.sourceLabel || 'Imported rule list'; } help.textContent = 'URL loaded into the preview box. Parent/account unlock is still required before any protected profile changes.'; + renderPreview(); textArea.focus(); } catch (error) { setLoadedSource(); @@ -7630,11 +8169,12 @@ document.addEventListener('DOMContentLoaded', async () => { const text = await readManagedChannelListFile(file); textArea.value = text; setLoadedSource(); - if (!normalizeString(nameInput.value) || nameInput.value === 'Imported channel list') { - nameInput.value = file.name.replace(/\.[^.]+$/, '') || 'Imported channel list'; + if (!normalizeString(nameInput.value) || nameInput.value === 'Imported rule list') { + nameInput.value = file.name.replace(/\.[^.]+$/, '') || 'Imported rule list'; } help.textContent = 'File loaded into the preview box. Parent/account unlock is still required before any protected profile changes.'; setError(''); + renderPreview(); textArea.focus(); } catch (error) { setError(error?.message || 'Unable to read this file.'); @@ -7644,6 +8184,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (loadedSourceUrl && normalizeString(textArea.value) !== loadedSourceText) { setLoadedSource(); } + renderPreview(); }); textArea.addEventListener('keydown', (event) => { if (event.key === 'Escape') { @@ -7660,6 +8201,7 @@ document.addEventListener('DOMContentLoaded', async () => { card.append(header, body); overlay.appendChild(card); document.body.appendChild(overlay); + renderPreview(); setTimeout(() => { try { nameInput.focus(); @@ -7673,7 +8215,7 @@ document.addEventListener('DOMContentLoaded', async () => { async function promptManagedChannelListSurface() { const surface = await showChoiceModal({ title: 'Where Should This List Apply?', - message: 'Choose the YouTube space for these channels. FilterTube will respect each protected profile current Blocklist/Whitelist mode.', + message: 'Choose the YouTube space for these rules. FilterTube will respect each protected profile current Blocklist/Whitelist mode.', choices: [ { value: 'main', label: 'Main YouTube', className: 'btn-primary' }, { value: 'kids', label: 'YouTube Kids', className: 'btn-secondary' }, @@ -7689,54 +8231,74 @@ document.addEventListener('DOMContentLoaded', async () => { function applyManagedChannelListToSurface(target, surface, parsed, metadata = {}) { const item = safeObject(target); - const listKey = managedRuleListKeyFor(surface, 'channel', item); - const existing = Array.isArray(item[listKey]) ? item[listKey] : []; - const seen = new Set(existing.map(managedChannelEntryKey).filter(Boolean)); - const toAdd = []; + const channelListKey = managedRuleListKeyFor(surface, 'channel', item); + const keywordListKey = managedRuleListKeyFor(surface, 'keyword', item); + const existingChannels = Array.isArray(item[channelListKey]) ? item[channelListKey] : []; + const existingKeywords = Array.isArray(item[keywordListKey]) ? item[keywordListKey] : []; + const seenChannels = new Set(existingChannels.map(managedChannelEntryKey).filter(Boolean)); + const seenKeywords = new Set(existingKeywords.map(row => normalizeString(row?.word).toLowerCase()).filter(Boolean)); + const channelsToAdd = []; + const keywordsToAdd = []; let duplicateCount = 0; const sourceMetadata = { ...safeObject(parsed?.sourceMetadata), ...safeObject(metadata.sourceMetadata) }; + const decorateManagedListRow = (row) => ({ + ...row, + source: 'managed_channel_list', + managedListId: normalizeString(parsed?.listId), + managedListName: normalizeString(metadata.listName) || normalizeString(row.managedListName) || 'Imported rule list', + managedListSourceLabel: normalizeString(metadata.sourceLabel) || 'Imported list', + managedListSourceUrl: normalizeManagedChannelListSourceUrl(metadata.sourceUrl), + managedListSourceFormat: normalizeManagedChannelListSourceFormat(metadata.sourceFormat || parsed?.sourceFormat), + managedListImportedAt: metadata.importedAt || Date.now(), + managedListLastCheckedAt: metadata.lastCheckedAt || metadata.importedAt || Date.now(), + managedListContentHash: normalizeString(metadata.contentHash || parsed?.contentHash), + ...(normalizeString(sourceMetadata.title) ? { managedListSourceTitle: normalizeManagedChannelListMetadataValue(sourceMetadata.title) } : {}), + ...(normalizeString(sourceMetadata.sourceVersion) ? { managedListSourceVersion: normalizeManagedChannelListMetadataValue(sourceMetadata.sourceVersion) } : {}), + ...(normalizeString(sourceMetadata.sourceUpdatedLabel) ? { managedListSourceUpdatedLabel: normalizeManagedChannelListMetadataValue(sourceMetadata.sourceUpdatedLabel) } : {}), + ...(normalizeString(sourceMetadata.homepage) ? { managedListSourceHomepage: normalizeManagedChannelListMetadataValue(sourceMetadata.homepage, 240) } : {}), + ...(metadata.paused === true ? { managedListPaused: true } : {}), + addedAt: metadata.importedAt || Date.now() + }); safeArray(parsed?.channels).forEach((channel) => { const key = managedChannelEntryKey(channel); - if (!key || seen.has(key)) { + if (!key || seenChannels.has(key)) { duplicateCount += 1; return; } - seen.add(key); - toAdd.push({ - ...channel, - source: 'managed_channel_list', - managedListId: normalizeString(parsed?.listId), - managedListName: normalizeString(metadata.listName) || normalizeString(channel.managedListName) || 'Imported channel list', - managedListSourceLabel: normalizeString(metadata.sourceLabel) || 'Imported list', - managedListSourceUrl: normalizeManagedChannelListSourceUrl(metadata.sourceUrl), - managedListSourceFormat: normalizeManagedChannelListSourceFormat(metadata.sourceFormat || parsed?.sourceFormat), - managedListImportedAt: metadata.importedAt || Date.now(), - managedListLastCheckedAt: metadata.lastCheckedAt || metadata.importedAt || Date.now(), - managedListContentHash: normalizeString(metadata.contentHash || parsed?.contentHash), - ...(normalizeString(sourceMetadata.title) ? { managedListSourceTitle: normalizeManagedChannelListMetadataValue(sourceMetadata.title) } : {}), - ...(normalizeString(sourceMetadata.sourceVersion) ? { managedListSourceVersion: normalizeManagedChannelListMetadataValue(sourceMetadata.sourceVersion) } : {}), - ...(normalizeString(sourceMetadata.sourceUpdatedLabel) ? { managedListSourceUpdatedLabel: normalizeManagedChannelListMetadataValue(sourceMetadata.sourceUpdatedLabel) } : {}), - ...(normalizeString(sourceMetadata.homepage) ? { managedListSourceHomepage: normalizeManagedChannelListMetadataValue(sourceMetadata.homepage, 240) } : {}), - ...(metadata.paused === true ? { managedListPaused: true } : {}), - addedAt: metadata.importedAt || Date.now() - }); + seenChannels.add(key); + channelsToAdd.push(decorateManagedListRow(channel)); + }); + safeArray(parsed?.keywords).forEach((keyword) => { + const entry = normalizeProfileKeyword(keyword?.word || keyword, { comments: surface !== 'kids' }); + const key = normalizeString(entry?.word).toLowerCase(); + if (!key || seenKeywords.has(key)) { + duplicateCount += 1; + return; + } + seenKeywords.add(key); + keywordsToAdd.push(decorateManagedListRow(entry)); }); - if (toAdd.length) { - item[listKey] = [...toAdd, ...existing]; + if (channelsToAdd.length) { + item[channelListKey] = [...channelsToAdd, ...existingChannels]; + } + if (keywordsToAdd.length) { + item[keywordListKey] = [...keywordsToAdd, ...existingKeywords]; } return { - changed: toAdd.length > 0, - addedCount: toAdd.length, + changed: channelsToAdd.length > 0 || keywordsToAdd.length > 0, + addedCount: channelsToAdd.length + keywordsToAdd.length, + channelAddedCount: channelsToAdd.length, + keywordAddedCount: keywordsToAdd.length, duplicateCount }; } function getManagedChannelListSurfaceKeys(surface) { - if (surface === 'kids') return ['blockedChannels', 'whitelistChannels']; - return ['channels', 'whitelistChannels']; + if (surface === 'kids') return ['blockedChannels', 'whitelistChannels', 'blockedKeywords', 'whitelistKeywords']; + return ['channels', 'whitelistChannels', 'keywords', 'whitelistKeywords']; } function collectManagedChannelListSummariesForProfile(profile, profileId) { @@ -7749,7 +8311,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (!listId) return; const current = summaries.get(listId) || { listId, - listName: normalizeString(row?.managedListName) || 'Imported channel list', + listName: normalizeString(row?.managedListName) || 'Imported rule list', sourceLabel: normalizeString(row?.managedListSourceLabel) || 'Imported list', sourceUrl: normalizeManagedChannelListSourceUrl(row?.managedListSourceUrl), sourceFormat: normalizeManagedChannelListSourceFormat(row?.managedListSourceFormat), @@ -7761,13 +8323,13 @@ document.addEventListener('DOMContentLoaded', async () => { lastCheckedAt: 0, profileIds: new Set(), surfaces: new Set(), - channelCount: 0, - activeChannelCount: 0, - pausedChannelCount: 0 + ruleCount: 0, + activeRuleCount: 0, + pausedRuleCount: 0 }; current.profileIds.add(normalizeString(profileId)); current.surfaces.add(surface); - current.channelCount += 1; + current.ruleCount += 1; const checkedAt = Number(row?.managedListLastCheckedAt || row?.managedListImportedAt) || 0; if (checkedAt > current.lastCheckedAt) current.lastCheckedAt = checkedAt; if (!current.contentHash && normalizeString(row?.managedListContentHash)) { @@ -7781,9 +8343,9 @@ document.addEventListener('DOMContentLoaded', async () => { if (!current.sourceUpdatedLabel && normalizeString(row?.managedListSourceUpdatedLabel)) current.sourceUpdatedLabel = normalizeString(row.managedListSourceUpdatedLabel); if (!current.sourceHomepage && normalizeString(row?.managedListSourceHomepage)) current.sourceHomepage = normalizeString(row.managedListSourceHomepage); if (isManagedChannelListRowPaused(row)) { - current.pausedChannelCount += 1; + current.pausedRuleCount += 1; } else { - current.activeChannelCount += 1; + current.activeRuleCount += 1; } summaries.set(listId, current); }); @@ -7803,9 +8365,12 @@ document.addEventListener('DOMContentLoaded', async () => { lastCheckedAt: item.lastCheckedAt, profileIds: Array.from(item.profileIds).filter(Boolean), surfaces: Array.from(item.surfaces).filter(Boolean), - channelCount: item.channelCount, - activeChannelCount: item.activeChannelCount, - pausedChannelCount: item.pausedChannelCount + ruleCount: item.ruleCount, + activeRuleCount: item.activeRuleCount, + pausedRuleCount: item.pausedRuleCount, + channelCount: item.ruleCount, + activeChannelCount: item.activeRuleCount, + pausedChannelCount: item.pausedRuleCount })); } @@ -7830,15 +8395,15 @@ document.addEventListener('DOMContentLoaded', async () => { lastCheckedAt: Number(summary.lastCheckedAt) || 0, profileIds: new Set(), surfaces: new Set(), - channelCount: 0, - activeChannelCount: 0, - pausedChannelCount: 0 + ruleCount: 0, + activeRuleCount: 0, + pausedRuleCount: 0 }; safeArray(summary.profileIds).forEach(id => current.profileIds.add(id)); safeArray(summary.surfaces).forEach(surface => current.surfaces.add(surface)); - current.channelCount += Number(summary.channelCount) || 0; - current.activeChannelCount += Number(summary.activeChannelCount) || 0; - current.pausedChannelCount += Number(summary.pausedChannelCount) || 0; + current.ruleCount += Number(summary.ruleCount) || 0; + current.activeRuleCount += Number(summary.activeRuleCount) || 0; + current.pausedRuleCount += Number(summary.pausedRuleCount) || 0; current.lastCheckedAt = Math.max(current.lastCheckedAt || 0, Number(summary.lastCheckedAt) || 0); if (!current.contentHash && normalizeString(summary.contentHash)) current.contentHash = normalizeString(summary.contentHash); if (!current.sourceFormat && normalizeManagedChannelListSourceFormat(summary.sourceFormat)) current.sourceFormat = normalizeManagedChannelListSourceFormat(summary.sourceFormat); @@ -7864,9 +8429,12 @@ document.addEventListener('DOMContentLoaded', async () => { lastCheckedAt: item.lastCheckedAt, profileIds: Array.from(item.profileIds).filter(Boolean), surfaces: Array.from(item.surfaces).filter(Boolean), - channelCount: item.channelCount, - activeChannelCount: item.activeChannelCount, - pausedChannelCount: item.pausedChannelCount + ruleCount: item.ruleCount, + activeRuleCount: item.activeRuleCount, + pausedRuleCount: item.pausedRuleCount, + channelCount: item.ruleCount, + activeChannelCount: item.activeRuleCount, + pausedChannelCount: item.pausedRuleCount })) .sort((a, b) => (a.listName || '').localeCompare(b.listName || '')); } @@ -7909,7 +8477,7 @@ document.addEventListener('DOMContentLoaded', async () => { async function showManagedChannelListLibraryModal(summaries) { const rows = safeArray(summaries); if (!rows.length) { - UIComponents.showToast('No imported channel lists found for the selected protected profiles', 'info'); + UIComponents.showToast('No imported rule lists found for the selected protected profiles', 'info'); return; } return new Promise((resolve) => { @@ -7923,7 +8491,7 @@ document.addEventListener('DOMContentLoaded', async () => { header.className = 'card-header'; const titleEl = document.createElement('h3'); titleEl.className = 'ft-modal-title'; - titleEl.textContent = 'Imported Channel Lists'; + titleEl.textContent = 'Imported Rule Lists'; header.appendChild(titleEl); const body = document.createElement('div'); @@ -7931,7 +8499,7 @@ document.addEventListener('DOMContentLoaded', async () => { const intro = document.createElement('div'); intro.className = 'import-export-hint'; - intro.textContent = 'These are list-derived channel rules on the selected protected profiles. This view is read-only.'; + intro.textContent = 'These are list-derived channel and keyword rules on the selected protected profiles. This view is read-only.'; body.appendChild(intro); const list = document.createElement('div'); @@ -7941,19 +8509,20 @@ document.addEventListener('DOMContentLoaded', async () => { item.className = 'managed-channel-list-modal__library-item'; const title = document.createElement('strong'); - title.textContent = summary.listName || 'Imported channel list'; + title.textContent = summary.listName || 'Imported rule list'; const meta = document.createElement('div'); meta.className = 'managed-channel-list-modal__library-meta'; const surfaces = summary.surfaces.includes('main') && summary.surfaces.includes('kids') ? 'Main + Kids' : (summary.surfaces.includes('kids') ? 'Kids' : 'Main'); - const activeCount = Number(summary.activeChannelCount) || 0; - const pausedCount = Number(summary.pausedChannelCount) || 0; + const activeCount = Number(summary.activeRuleCount ?? summary.activeChannelCount) || 0; + const pausedCount = Number(summary.pausedRuleCount ?? summary.pausedChannelCount) || 0; const stateBits = pausedCount ? `${activeCount} active, ${pausedCount} paused` - : `${activeCount || summary.channelCount || 0} active`; - meta.textContent = `${summary.channelCount || 0} ${pluralize(summary.channelCount || 0, 'channel')} | ${stateBits} | ${summary.profileIds.length} ${pluralize(summary.profileIds.length, 'profile')} | ${surfaces}`; + : `${activeCount || summary.ruleCount || summary.channelCount || 0} active`; + const ruleCount = Number(summary.ruleCount ?? summary.channelCount) || 0; + meta.textContent = `${ruleCount} ${pluralize(ruleCount, 'rule')} | ${stateBits} | ${summary.profileIds.length} ${pluralize(summary.profileIds.length, 'profile')} | ${surfaces}`; const source = document.createElement('small'); source.className = 'managed-channel-list-modal__library-source'; @@ -8010,18 +8579,19 @@ document.addEventListener('DOMContentLoaded', async () => { async function promptManagedChannelListToRemove(summaries) { const choices = safeArray(summaries).slice(0, 10).map((summary) => ({ value: summary.listId, - label: `${summary.listName} (${summary.channelCount})`, + label: `${summary.listName} (${summary.ruleCount ?? summary.channelCount})`, className: 'btn-secondary' })); if (!choices.length) return null; const selected = await showChoiceModal({ title: 'Remove Imported List', - message: 'Choose the imported channel list to remove from selected protected profiles. Manual channel rules are kept.', + message: 'Choose the imported rule list to remove from selected protected profiles. Manual rules are kept.', details: safeArray(summaries).slice(0, 5).map((summary) => { const surfaces = summary.surfaces.includes('main') && summary.surfaces.includes('kids') ? 'Main + Kids' : (summary.surfaces.includes('kids') ? 'Kids' : 'Main'); - return `${summary.listName}: ${summary.channelCount} list-derived ${pluralize(summary.channelCount, 'channel')} across ${summary.profileIds.length} ${pluralize(summary.profileIds.length, 'profile')} (${surfaces})`; + const ruleCount = Number(summary.ruleCount ?? summary.channelCount) || 0; + return `${summary.listName}: ${ruleCount} list-derived ${pluralize(ruleCount, 'rule')} across ${summary.profileIds.length} ${pluralize(summary.profileIds.length, 'profile')} (${surfaces})`; }), choices, cancelText: 'Cancel' @@ -8033,7 +8603,7 @@ document.addEventListener('DOMContentLoaded', async () => { const urlBacked = safeArray(summaries).filter(summary => normalizeManagedChannelListSourceUrl(summary?.sourceUrl)); const choices = urlBacked.slice(0, 10).map((summary) => ({ value: summary.listId, - label: `${summary.listName} (${summary.channelCount})`, + label: `${summary.listName} (${summary.ruleCount ?? summary.channelCount})`, className: 'btn-secondary' })); if (!choices.length) return null; @@ -8044,7 +8614,8 @@ document.addEventListener('DOMContentLoaded', async () => { const surfaces = summary.surfaces.includes('main') && summary.surfaces.includes('kids') ? 'Main + Kids' : (summary.surfaces.includes('kids') ? 'Kids' : 'Main'); - return `${summary.listName}: ${summary.channelCount} current ${pluralize(summary.channelCount, 'channel')} across ${summary.profileIds.length} ${pluralize(summary.profileIds.length, 'profile')} (${surfaces})`; + const ruleCount = Number(summary.ruleCount ?? summary.channelCount) || 0; + return `${summary.listName}: ${ruleCount} current ${pluralize(ruleCount, 'rule')} across ${summary.profileIds.length} ${pluralize(summary.profileIds.length, 'profile')} (${surfaces})`; }), choices, cancelText: 'Cancel' @@ -8056,8 +8627,9 @@ document.addEventListener('DOMContentLoaded', async () => { const selectedList = safeObject(summary); const loaded = await fetchManagedChannelListSourceUrl(selectedList.sourceUrl); const parsedRaw = parseManagedChannelListText(loaded.text, { listName: selectedList.listName }); - if (!parsedRaw.channels.length) { - throw new Error('No valid channel identifiers found'); + const counts = countManagedRuleListRows(parsedRaw); + if (!counts.total) { + throw new Error('No valid channels or keywords found'); } return { selectedList, @@ -8071,13 +8643,13 @@ document.addEventListener('DOMContentLoaded', async () => { async function promptManagedChannelListToPauseState(summaries, paused) { const candidates = safeArray(summaries).filter((summary) => { - const activeCount = Number(summary?.activeChannelCount) || 0; - const pausedCount = Number(summary?.pausedChannelCount) || 0; + const activeCount = Number(summary?.activeRuleCount ?? summary?.activeChannelCount) || 0; + const pausedCount = Number(summary?.pausedRuleCount ?? summary?.pausedChannelCount) || 0; return paused ? activeCount > 0 : pausedCount > 0; }); const choices = candidates.slice(0, 10).map((summary) => ({ value: summary.listId, - label: `${summary.listName} (${paused ? (Number(summary.activeChannelCount) || 0) : (Number(summary.pausedChannelCount) || 0)})`, + label: `${summary.listName} (${paused ? (Number(summary.activeRuleCount ?? summary.activeChannelCount) || 0) : (Number(summary.pausedRuleCount ?? summary.pausedChannelCount) || 0)})`, className: paused ? 'btn-secondary' : 'btn-primary' })); if (!choices.length) return null; @@ -8087,8 +8659,8 @@ document.addEventListener('DOMContentLoaded', async () => { ? 'Choose the imported list to pause. The list remains saved and visible, but its channels stop affecting protected profiles until resumed.' : 'Choose the imported list to resume. Its saved channels will become active again after parent/account unlock.', details: candidates.slice(0, 5).map((summary) => { - const activeCount = Number(summary.activeChannelCount) || 0; - const pausedCount = Number(summary.pausedChannelCount) || 0; + const activeCount = Number(summary.activeRuleCount ?? summary.activeChannelCount) || 0; + const pausedCount = Number(summary.pausedRuleCount ?? summary.pausedChannelCount) || 0; const surfaces = summary.surfaces.includes('main') && summary.surfaces.includes('kids') ? 'Main + Kids' : (summary.surfaces.includes('kids') ? 'Kids' : 'Main'); @@ -8251,10 +8823,10 @@ document.addEventListener('DOMContentLoaded', async () => { actionType: 'policy.channel_list.check', summary: { ...safeObject(report.historyRow.summary), - label: 'Channel list checked', + label: 'Rule list checked', surface, checkedCount: result.changedCount || 0, - listEntryCount: safeArray(parsed?.channels).length, + listEntryCount: countManagedRuleListRows(parsed).total, contentChanged: false } }; @@ -8314,8 +8886,8 @@ document.addEventListener('DOMContentLoaded', async () => { importedAt: refreshedAt, lastCheckedAt: refreshedAt, contentHash: parsed.contentHash, - paused: (Number(selectedList.pausedChannelCount) || 0) > 0 - && (Number(selectedList.activeChannelCount) || 0) <= 0 + paused: (Number(selectedList.pausedRuleCount ?? selectedList.pausedChannelCount) || 0) > 0 + && (Number(selectedList.activeRuleCount ?? selectedList.activeChannelCount) || 0) <= 0 }); if (!removeResult.changed && !applyResult.changed) continue; nextProfile = setProfileSurface(nextProfile, surface, nextSurface); @@ -8331,13 +8903,13 @@ document.addEventListener('DOMContentLoaded', async () => { actionType: 'policy.channel_list.refresh', summary: { ...safeObject(report.historyRow.summary), - label: 'Channel list refreshed', + label: 'Rule list refreshed', surface, addedCount: applyResult.addedCount || 0, removedCount: removeResult.removedCount || 0, duplicateCount: applyResult.duplicateCount || 0, skippedCount: parsed.skippedCount || 0, - listEntryCount: parsed.channels.length + listEntryCount: countManagedRuleListRows(parsed).total } }; nextProfile = recordManagedChildLocalEditHistory(nextProfile, report); @@ -8504,7 +9076,7 @@ document.addEventListener('DOMContentLoaded', async () => { } } - async function importManagedChannelListToProfiles(profileIds) { + async function importManagedChannelListToProfiles(profileIds, options = {}) { const targetIds = [...new Set(safeArray(profileIds).map(normalizeString).filter(Boolean))]; if (!targetIds.length) { UIComponents.showToast('Select at least one protected profile', 'error'); @@ -8534,23 +9106,30 @@ document.addEventListener('DOMContentLoaded', async () => { return; } - const importPayload = await showManagedChannelListImportModal({ selectedCount: eligibleIds.length }); + const importPayload = await showManagedChannelListImportModal({ + selectedCount: eligibleIds.length, + targetLabel: normalizeString(options.targetLabel) + }); if (!importPayload) return; const parsed = parseManagedChannelListText(importPayload.text, { listName: importPayload.name }); - if (!parsed.channels.length) { - UIComponents.showToast('No valid channel identifiers found. Use @handle, UC ID, /c/name, /user/name, or channel URLs.', 'error'); + const parsedCounts = countManagedRuleListRows(parsed); + if (!parsedCounts.total) { + UIComponents.showToast('No valid channels or keywords found. CSV can use channel_id and keyword columns.', 'error'); return; } - const surfaces = await promptManagedChannelListSurface(); + const fixedSurfaces = [...new Set(safeArray(options.surfaces) + .map(surface => surface === 'kids' ? 'kids' : (surface === 'main' ? 'main' : '')) + .filter(Boolean))]; + const surfaces = fixedSurfaces.length ? fixedSurfaces : await promptManagedChannelListSurface(); if (!surfaces.length) return; const surfaceLabel = surfaces.length > 1 ? 'Main + Kids' : (surfaces[0] === 'kids' ? 'YouTube Kids' : 'Main YouTube'); const confirmImport = await showChoiceModal({ - title: 'Apply Channel List?', - message: `${parsed.channels.length} channel ${parsed.channels.length === 1 ? 'identifier was' : 'identifiers were'} found. Apply this list to ${eligibleIds.length} protected ${eligibleIds.length === 1 ? 'profile' : 'profiles'} on ${surfaceLabel}.`, + title: 'Apply Rule List?', + message: `${formatManagedRuleListCount(parsedCounts)} found. Apply this list to ${eligibleIds.length} protected ${eligibleIds.length === 1 ? 'profile' : 'profiles'} on ${surfaceLabel}.`, details: [ - `${parsed.channels.length} valid ${parsed.channels.length === 1 ? 'channel' : 'channels'} ready`, + `${formatManagedRuleListCount(parsedCounts)} ready`, parsed.skippedCount ? `${parsed.skippedCount} ${parsed.skippedCount === 1 ? 'row was' : 'rows were'} skipped` : 'No rows skipped', 'Parent/account re-auth is required before anything changes.' ], @@ -8608,12 +9187,16 @@ document.addEventListener('DOMContentLoaded', async () => { actionType: 'policy.channel_list.import', summary: { ...safeObject(report.historyRow.summary), - label: 'Channel list imported', + label: 'Rule list imported', surface: surfaces.length > 1 ? 'both' : surface, addedCount: result.addedCount || 0, + channelAddedCount: result.channelAddedCount || 0, + keywordAddedCount: result.keywordAddedCount || 0, duplicateCount: result.duplicateCount || 0, skippedCount: parsed.skippedCount || 0, - listEntryCount: parsed.channels.length + listEntryCount: parsedCounts.total, + channelCount: parsedCounts.channels, + keywordCount: parsedCounts.keywords } }; nextProfile = recordManagedChildLocalEditHistory(nextProfile, report); @@ -8647,12 +9230,16 @@ document.addEventListener('DOMContentLoaded', async () => { await refreshProfilesUI(); renderChannels(); renderKidsChannels(); + renderKeywords(); + renderKidsKeywords(); UIComponents.showToast( - `Imported ${addedCount} ${addedCount === 1 ? 'channel' : 'channels'} into ${changedCount} protected ${changedCount === 1 ? 'profile' : 'profiles'}`, + `Imported ${addedCount} list-derived ${pluralize(addedCount, 'rule')} into ${changedCount} protected ${changedCount === 1 ? 'profile' : 'profiles'}`, 'success' ); - const remoteScope = surfaces.length > 1 ? 'rules_bundle' : 'channels'; + const remoteScope = surfaces.length > 1 || (parsedCounts.channels && parsedCounts.keywords) + ? 'rules_bundle' + : (parsedCounts.keywords ? 'keywords' : 'channels'); const mailboxReady = hasNanahManagedMailboxUploadWriter(); const localReady = hasNanahManagedLocalNetworkDeliveryWriter(); const readyProfileCount = changedProfileIds.filter((targetId) => { @@ -8664,7 +9251,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (readyProfileCount > 0) { const sendNow = await showConfirmModal({ title: 'Send list update now?', - message: `${readyProfileCount} changed ${readyProfileCount === 1 ? 'profile has' : 'profiles have'} a verified delivery path. Send this channel-list update to those devices now.`, + message: `${readyProfileCount} changed ${readyProfileCount === 1 ? 'profile has' : 'profiles have'} a verified delivery path. Send this rule-list update to those devices now.`, confirmText: 'Send update', cancelText: 'Not now' }); @@ -8677,6 +9264,133 @@ document.addEventListener('DOMContentLoaded', async () => { } } + function formatRuleListSurfaceLabel(surfaces) { + const items = [...new Set(safeArray(surfaces).map(surface => surface === 'kids' ? 'kids' : (surface === 'main' ? 'main' : '')).filter(Boolean))]; + if (items.length > 1) return 'Main YouTube + YouTube Kids'; + return items[0] === 'kids' ? 'YouTube Kids' : 'Main YouTube'; + } + + function getSettingsRuleListImportSurfaces() { + const selected = document.querySelector('input[name="ftRuleListImportTarget"]:checked'); + const value = normalizeString(selected?.value).toLowerCase(); + if (value === 'kids') return ['kids']; + if (value === 'both') return ['main', 'kids']; + return ['main']; + } + + async function importManagedRuleListToActiveProfileSurfaces(surfaces) { + const targetSurfaces = [...new Set(safeArray(surfaces) + .map(surface => surface === 'kids' ? 'kids' : (surface === 'main' ? 'main' : '')) + .filter(Boolean))]; + if (!targetSurfaces.length) targetSurfaces.push('main'); + const surfaceLabel = formatRuleListSurfaceLabel(targetSurfaces); + const managedProfileId = normalizeString(managedChildEdit?.profileId); + if (managedProfileId && targetSurfaces.every(surface => isManagedChildEditFor(surface))) { + await importManagedChannelListToProfiles([managedProfileId], { + surfaces: targetSurfaces, + targetLabel: `${getProfileName(profilesV4Cache, managedProfileId)} ${surfaceLabel} rules` + }); + return; + } + + const io = window.FilterTubeIO || {}; + if (typeof io.loadProfilesV4 !== 'function' || typeof io.saveProfilesV4 !== 'function') { + UIComponents.showToast('Profiles unavailable', 'error'); + return; + } + + const fresh = await io.loadProfilesV4(); + const currentActive = normalizeString(fresh?.activeProfileId) || activeProfileId || 'default'; + if (getProfileType(fresh, currentActive) === 'child') { + UIComponents.showToast('Open the parent/account profile to import rules for a child profile', 'error'); + return; + } + + const profiles = { ...safeObject(fresh.profiles) }; + const profile = safeObject(profiles[currentActive]); + if (!profile || Object.keys(profile).length === 0) { + UIComponents.showToast('Active profile is unavailable', 'error'); + return; + } + + const importPayload = await showManagedChannelListImportModal({ + selectedCount: 1, + targetLabel: `${getProfileName(fresh, currentActive)} ${surfaceLabel} rules` + }); + if (!importPayload) return; + + const parsed = parseManagedChannelListText(importPayload.text, { listName: importPayload.name }); + const parsedCounts = countManagedRuleListRows(parsed); + if (!parsedCounts.total) { + UIComponents.showToast('No valid channels or keywords found. CSV can use channel_id and keyword columns.', 'error'); + return; + } + + const confirmImport = await showChoiceModal({ + title: `Apply CSV to ${surfaceLabel}?`, + message: `${formatManagedRuleListCount(parsedCounts)} found. Apply this list to ${getProfileName(fresh, currentActive)} on ${surfaceLabel}.`, + details: [ + `${formatManagedRuleListCount(parsedCounts)} ready`, + parsed.skippedCount ? `${parsed.skippedCount} ${parsed.skippedCount === 1 ? 'row was' : 'rows were'} skipped` : 'No rows skipped', + 'Nothing changes until you confirm.' + ], + choices: [ + { value: 'apply', label: 'Apply List', className: 'btn-primary' } + ], + cancelText: 'Cancel' + }); + if (confirmImport !== 'apply') return; + + const okAdmin = await ensureProfileUnlocked(fresh, currentActive, { sensitiveAction: true }); + if (!okAdmin) return; + + const importedAt = Date.now(); + let nextProfile = profile; + let changed = false; + let addedCount = 0; + let duplicateCount = 0; + for (const targetSurface of targetSurfaces) { + const nextSurface = getProfileSurface(nextProfile, targetSurface); + const result = applyManagedChannelListToSurface(nextSurface, targetSurface, parsed, { + listName: importPayload.name, + sourceLabel: importPayload.sourceLabel, + sourceUrl: importPayload.sourceUrl, + importedAt, + lastCheckedAt: importedAt, + contentHash: parsed.contentHash + }); + duplicateCount += result.duplicateCount || 0; + if (!result.changed) continue; + nextProfile = setProfileSurface(nextProfile, targetSurface, nextSurface); + changed = true; + addedCount += result.addedCount || 0; + } + + if (!changed) { + UIComponents.showToast( + duplicateCount ? 'This profile already has that list' : 'No profile rules were changed', + 'info' + ); + return; + } + + profiles[currentActive] = nextProfile; + await io.saveProfilesV4({ + ...fresh, + schemaVersion: 4, + activeProfileId: currentActive, + profiles + }); + profilesV4Cache = { ...fresh, schemaVersion: 4, activeProfileId: currentActive, profiles }; + await StateManager.loadSettings({ notify: false, resetEnrichment: false, scheduleEnrichment: false }); + await refreshProfilesUI(); + renderChannels(); + renderKidsChannels(); + renderKeywords(); + renderKidsKeywords(); + UIComponents.showToast(`Imported ${addedCount} ${pluralize(addedCount, 'rule')} into ${surfaceLabel}`, 'success'); + } + async function removeManagedChannelListFromProfiles(profileIds) { const targetIds = [...new Set(safeArray(profileIds).map(normalizeString).filter(Boolean))]; if (!targetIds.length) { @@ -8709,7 +9423,7 @@ document.addEventListener('DOMContentLoaded', async () => { const summaries = collectManagedChannelListSummaries(profiles, eligibleIds); if (!summaries.length) { - UIComponents.showToast('No imported channel lists found for the selected protected profiles', 'info'); + UIComponents.showToast('No imported rule lists found for the selected protected profiles', 'info'); return; } @@ -8718,7 +9432,7 @@ document.addEventListener('DOMContentLoaded', async () => { const confirmRemove = await showConfirmModal({ title: `Remove ${selectedList.listName}?`, - message: `This removes ${selectedList.channelCount} list-derived ${pluralize(selectedList.channelCount, 'channel')} from ${selectedList.profileIds.length} selected ${pluralize(selectedList.profileIds.length, 'profile')}. Manual channel rules stay untouched.`, + message: `This removes ${selectedList.ruleCount || selectedList.channelCount} list-derived ${pluralize(selectedList.ruleCount || selectedList.channelCount, 'rule')} from ${selectedList.profileIds.length} selected ${pluralize(selectedList.profileIds.length, 'profile')}. Manual rules stay untouched.`, confirmText: 'Remove List', cancelText: 'Cancel' }); @@ -8761,10 +9475,10 @@ document.addEventListener('DOMContentLoaded', async () => { actionType: 'policy.channel_list.remove', summary: { ...safeObject(report.historyRow.summary), - label: 'Channel list removed', + label: 'Rule list removed', surface, removedCount: result.removedCount || 0, - listEntryCount: selectedList.channelCount || 0 + listEntryCount: selectedList.ruleCount || selectedList.channelCount || 0 } }; nextProfile = recordManagedChildLocalEditHistory(nextProfile, report); @@ -8795,12 +9509,14 @@ document.addEventListener('DOMContentLoaded', async () => { await refreshProfilesUI(); renderChannels(); renderKidsChannels(); + renderKeywords(); + renderKidsKeywords(); UIComponents.showToast( - `Removed ${removedCount} list-derived ${pluralize(removedCount, 'channel')} from ${changedCount} protected ${pluralize(changedCount, 'profile')}`, + `Removed ${removedCount} list-derived ${pluralize(removedCount, 'rule')} from ${changedCount} protected ${pluralize(changedCount, 'profile')}`, 'success' ); - const remoteScope = changedSurfaces.size === 1 ? 'channels' : 'rules_bundle'; + const remoteScope = 'rules_bundle'; const surface = changedSurfaces.size === 1 ? Array.from(changedSurfaces)[0] : ''; const mailboxReady = hasNanahManagedMailboxUploadWriter(); const localReady = hasNanahManagedLocalNetworkDeliveryWriter(); @@ -8871,7 +9587,7 @@ document.addEventListener('DOMContentLoaded', async () => { const confirmChange = await showConfirmModal({ title: `${paused ? 'Pause' : 'Resume'} ${selectedList.listName}?`, message: paused - ? `This keeps ${selectedList.listName} saved, but stops its list-derived channels from affecting ${selectedList.profileIds.length} selected ${pluralize(selectedList.profileIds.length, 'profile')}. Manual channel rules stay active.` + ? `This keeps ${selectedList.listName} saved, but stops its list-derived rules from affecting ${selectedList.profileIds.length} selected ${pluralize(selectedList.profileIds.length, 'profile')}. Manual rules stay active.` : `This turns ${selectedList.listName} back on for ${selectedList.profileIds.length} selected ${pluralize(selectedList.profileIds.length, 'profile')}.`, confirmText: paused ? 'Pause List' : 'Resume List', cancelText: 'Cancel' @@ -8915,10 +9631,10 @@ document.addEventListener('DOMContentLoaded', async () => { actionType: paused ? 'policy.channel_list.pause' : 'policy.channel_list.resume', summary: { ...safeObject(report.historyRow.summary), - label: paused ? 'Channel list paused' : 'Channel list resumed', + label: paused ? 'Rule list paused' : 'Rule list resumed', surface, changedCount: result.changedCount || 0, - listEntryCount: selectedList.channelCount || 0 + listEntryCount: selectedList.ruleCount || selectedList.channelCount || 0 } }; nextProfile = recordManagedChildLocalEditHistory(nextProfile, report); @@ -8949,12 +9665,14 @@ document.addEventListener('DOMContentLoaded', async () => { await refreshProfilesUI(); renderChannels(); renderKidsChannels(); + renderKeywords(); + renderKidsKeywords(); UIComponents.showToast( - `${paused ? 'Paused' : 'Resumed'} ${changedRowCount} list-derived ${pluralize(changedRowCount, 'channel')} across ${changedProfileCount} protected ${pluralize(changedProfileCount, 'profile')}`, + `${paused ? 'Paused' : 'Resumed'} ${changedRowCount} list-derived ${pluralize(changedRowCount, 'rule')} across ${changedProfileCount} protected ${pluralize(changedProfileCount, 'profile')}`, 'success' ); - const remoteScope = changedSurfaces.size === 1 ? 'channels' : 'rules_bundle'; + const remoteScope = 'rules_bundle'; const surface = changedSurfaces.size === 1 ? Array.from(changedSurfaces)[0] : ''; const mailboxReady = hasNanahManagedMailboxUploadWriter(); const localReady = hasNanahManagedLocalNetworkDeliveryWriter(); @@ -9028,6 +9746,7 @@ document.addEventListener('DOMContentLoaded', async () => { } const parsed = refreshCandidate.parsed; + const parsedCounts = countManagedRuleListRows(parsed); const unchangedContentHash = normalizeString(selectedList.contentHash) && normalizeString(selectedList.contentHash) === normalizeString(parsed.contentHash); @@ -9035,13 +9754,13 @@ document.addEventListener('DOMContentLoaded', async () => { title: unchangedContentHash ? `Check ${selectedList.listName}?` : `Refresh ${selectedList.listName}?`, message: unchangedContentHash ? `The saved URL content matches the current source hash. FilterTube will update checked/source metadata only after parent/account unlock.` - : `This will replace the current list-derived rows for ${selectedList.profileIds.length} selected ${pluralize(selectedList.profileIds.length, 'profile')} with the latest channels from the saved URL.`, + : `This will replace the current list-derived rows for ${selectedList.profileIds.length} selected ${pluralize(selectedList.profileIds.length, 'profile')} with the latest rules from the saved URL.`, details: [ unchangedContentHash - ? `${selectedList.channelCount} current ${pluralize(selectedList.channelCount, 'channel')} stay unchanged` - : `${selectedList.channelCount} current ${pluralize(selectedList.channelCount, 'channel')} will be replaced where this list is present`, - `${parsed.channels.length} valid ${pluralize(parsed.channels.length, 'channel')} found in the latest URL content`, - unchangedContentHash ? 'No channel rows will be replaced because the source hash is unchanged' : 'Changed source content will replace matching list-derived rows', + ? `${selectedList.ruleCount || selectedList.channelCount} current ${pluralize(selectedList.ruleCount || selectedList.channelCount, 'rule')} stay unchanged` + : `${selectedList.ruleCount || selectedList.channelCount} current ${pluralize(selectedList.ruleCount || selectedList.channelCount, 'rule')} will be replaced where this list is present`, + `${formatManagedRuleListCount(parsedCounts)} found in the latest URL content`, + unchangedContentHash ? 'No rule rows will be replaced because the source hash is unchanged' : 'Changed source content will replace matching list-derived rows', parsed.skippedCount ? `${parsed.skippedCount} ${pluralize(parsed.skippedCount, 'row')} skipped for safety` : 'No rows skipped', 'Parent/account re-auth is required before anything changes.' ], @@ -9112,9 +9831,11 @@ document.addEventListener('DOMContentLoaded', async () => { await refreshProfilesUI(); renderChannels(); renderKidsChannels(); + renderKeywords(); + renderKidsKeywords(); if (unchangedContentHash) { UIComponents.showToast( - `Checked ${selectedList.listName}: no channel changes`, + `Checked ${selectedList.listName}: no rule changes`, 'success' ); return; @@ -9124,7 +9845,7 @@ document.addEventListener('DOMContentLoaded', async () => { 'success' ); - const remoteScope = changedSurfaces.size === 1 ? 'channels' : 'rules_bundle'; + const remoteScope = 'rules_bundle'; const surface = changedSurfaces.size === 1 ? Array.from(changedSurfaces)[0] : ''; const mailboxReady = hasNanahManagedMailboxUploadWriter(); const localReady = hasNanahManagedLocalNetworkDeliveryWriter(); @@ -9202,7 +9923,7 @@ document.addEventListener('DOMContentLoaded', async () => { loadedCandidates.push(await loadManagedChannelListRefreshCandidate(summary)); } catch (error) { failedCandidates.push({ - listName: normalizeString(summary?.listName) || 'Imported channel list', + listName: normalizeString(summary?.listName) || 'Imported rule list', error: error?.message || 'Unable to load' }); } @@ -9218,15 +9939,15 @@ document.addEventListener('DOMContentLoaded', async () => { return !priorHash || priorHash !== nextHash; }); const unchangedCandidates = loadedCandidates.filter(item => !contentChangedCandidates.includes(item)); - const totalValidChannels = loadedCandidates.reduce((total, item) => total + safeArray(item?.parsed?.channels).length, 0); + const totalValidRules = loadedCandidates.reduce((total, item) => total + countManagedRuleListRows(item?.parsed).total, 0); const totalSkippedRows = loadedCandidates.reduce((total, item) => total + (Number(item?.parsed?.skippedCount) || 0), 0); const confirmRefresh = await showChoiceModal({ title: staleOnly ? `Check ${loadedCandidates.length} stale URL ${pluralize(loadedCandidates.length, 'list')}?` : `Check ${loadedCandidates.length} URL-backed ${pluralize(loadedCandidates.length, 'list')}?`, - message: `FilterTube loaded ${loadedCandidates.length} ${staleOnly ? 'stale ' : ''}URL-backed ${pluralize(loadedCandidates.length, 'list')} for ${eligibleIds.length} selected protected ${pluralize(eligibleIds.length, 'profile')}. Changed sources will refresh channel rows; unchanged sources only update checked metadata.`, + message: `FilterTube loaded ${loadedCandidates.length} ${staleOnly ? 'stale ' : ''}URL-backed ${pluralize(loadedCandidates.length, 'list')} for ${eligibleIds.length} selected protected ${pluralize(eligibleIds.length, 'profile')}. Changed sources will refresh rule rows; unchanged sources only update checked metadata.`, details: [ - `${totalValidChannels} valid ${pluralize(totalValidChannels, 'channel')} found across loaded lists`, + `${totalValidRules} valid ${pluralize(totalValidRules, 'rule')} found across loaded lists`, `${contentChangedCandidates.length} ${pluralize(contentChangedCandidates.length, 'list')} changed | ${unchangedCandidates.length} unchanged`, totalSkippedRows ? `${totalSkippedRows} ${pluralize(totalSkippedRows, 'row')} skipped for safety` : 'No rows skipped', failedCandidates.length ? `${failedCandidates.length} ${pluralize(failedCandidates.length, 'list')} could not be loaded and will be left unchanged` : 'All URL-backed lists loaded', @@ -9308,6 +10029,8 @@ document.addEventListener('DOMContentLoaded', async () => { await refreshProfilesUI(); renderChannels(); renderKidsChannels(); + renderKeywords(); + renderKidsKeywords(); if (changedListCount) { UIComponents.showToast( `Refreshed ${changedListCount} ${pluralize(changedListCount, 'list')}: ${addedCount} added, ${removedCount} replaced${duplicateCount ? `, ${duplicateCount} already present` : ''}${checkedListCount ? `; checked ${checkedListCount} unchanged` : ''}`, @@ -9315,14 +10038,14 @@ document.addEventListener('DOMContentLoaded', async () => { ); } else { UIComponents.showToast( - `Checked ${checkedListCount} ${pluralize(checkedListCount, 'list')}: no channel changes${checkedRowCount ? ` across ${checkedRowCount} rows` : ''}`, + `Checked ${checkedListCount} ${pluralize(checkedListCount, 'list')}: no rule changes${checkedRowCount ? ` across ${checkedRowCount} rows` : ''}`, 'success' ); return; } const changedProfileIds = Array.from(changedProfileIdsSet); - const remoteScope = changedSurfaces.size === 1 ? 'channels' : 'rules_bundle'; + const remoteScope = 'rules_bundle'; const surface = changedSurfaces.size === 1 ? Array.from(changedSurfaces)[0] : ''; const mailboxReady = hasNanahManagedMailboxUploadWriter(); const localReady = hasNanahManagedLocalNetworkDeliveryWriter(); @@ -9381,8 +10104,8 @@ document.addEventListener('DOMContentLoaded', async () => { const summaries = collectManagedChannelListSummaries(profiles, eligibleIds); const urlBackedCount = summaries.filter(summary => isManagedChannelListSummaryUrlBacked(summary)).length; const staleUrlBackedCount = summaries.filter(summary => isManagedChannelListSummaryStale(summary)).length; - const activeListCount = summaries.filter(summary => (Number(summary.activeChannelCount) || 0) > 0).length; - const pausedListCount = summaries.filter(summary => (Number(summary.pausedChannelCount) || 0) > 0).length; + const activeListCount = summaries.filter(summary => (Number(summary.activeRuleCount ?? summary.activeChannelCount) || 0) > 0).length; + const pausedListCount = summaries.filter(summary => (Number(summary.pausedRuleCount ?? summary.pausedChannelCount) || 0) > 0).length; const details = summaries.length ? [ `${summaries.length} imported ${pluralize(summaries.length, 'list')} across ${eligibleIds.length} selected ${pluralize(eligibleIds.length, 'profile')}`, @@ -9392,7 +10115,7 @@ document.addEventListener('DOMContentLoaded', async () => { ] : [ `No imported lists yet for ${eligibleIds.length} selected ${pluralize(eligibleIds.length, 'profile')}`, - 'Start by importing a pasted, file, or public HTTPS channel list.' + 'Start by importing a pasted, file, or public HTTPS rule list.' ]; const choices = [ ...(summaries.length ? [{ value: 'view', label: 'View Lists', className: 'btn-secondary' }] : []), @@ -9405,8 +10128,8 @@ document.addEventListener('DOMContentLoaded', async () => { ...(summaries.length ? [{ value: 'remove', label: 'Remove List', className: 'btn-secondary' }] : []) ]; const selected = await showChoiceModal({ - title: 'Channel Lists', - message: 'Choose what to do with imported channel lists for the selected protected profiles.', + title: 'Rule Lists', + message: 'Choose what to do with imported rule lists for the selected protected profiles.', details, choices, cancelText: 'Cancel' @@ -12312,8 +13035,9 @@ document.addEventListener('DOMContentLoaded', async () => { title: 'Save Updates For Later', message: 'Use this only when a parent may change rules while the protected device is offline. The child device can pick up the update next time it opens.', details: [ - 'Skip this if both devices are usually open together.', - 'The server is only a waiting room for unreadable updates.', + 'This is advanced and separate from normal Nanah live P2P.', + 'Skip this if parent and protected devices can be opened together.', + 'A compatible service is only a waiting room for unreadable updates.', 'Parent approval and the saved trusted device still decide what applies.' ], configured: !!currentEndpoint, @@ -12328,15 +13052,15 @@ document.addEventListener('DOMContentLoaded', async () => { endpointHost: '' }); await refreshProfilesUI(); - UIComponents.showToast('Managed mailbox delivery disabled', 'success'); + UIComponents.showToast('Offline pickup disabled', 'success'); return; } const endpoint = await showPromptModal({ - title: 'Later Update Service', - message: 'Enter the HTTPS service that stores unreadable updates until the protected device opens. Leave blank to turn this off.', - placeholder: 'https://example.com/filtertube', + title: 'Offline Pickup Service', + message: 'Advanced only. This is not the Nanah signal server. Enter a compatible HTTPS pickup service only if you run one; leave blank to keep live P2P only.', + placeholder: 'https://your-filtertube-pickup-service', inputType: 'url', - confirmText: currentEndpoint ? 'Save Service' : 'Enable Later Updates', + confirmText: currentEndpoint ? 'Save Service' : 'Enable Offline Pickup', initialValue: currentEndpoint }); if (endpoint === null) return; @@ -12348,7 +13072,7 @@ document.addEventListener('DOMContentLoaded', async () => { endpointHost: '' }); await refreshProfilesUI(); - UIComponents.showToast('Managed mailbox delivery disabled', 'success'); + UIComponents.showToast('Offline pickup disabled', 'success'); return; } const token = await showPromptModal({ @@ -12375,7 +13099,7 @@ document.addEventListener('DOMContentLoaded', async () => { ? client.createProvider(nextConfig) : null; if (!provider || provider.configured !== true || !hasNanahManagedMailboxUploadWriter(provider)) { - UIComponents.showToast('Mailbox endpoint must be public HTTPS and supported by the encrypted mailbox client', 'error'); + UIComponents.showToast('Offline pickup endpoint must be public HTTPS and supported by FilterTube', 'error'); return; } writeNanahManagedMailboxServerConfig(nextConfig); @@ -12384,7 +13108,7 @@ document.addEventListener('DOMContentLoaded', async () => { endpointHost: getManagedMailboxEndpointHostFromConfig(nextConfig) }); await refreshProfilesUI(); - UIComponents.showToast('Managed mailbox provider saved', 'success'); + UIComponents.showToast('Offline pickup saved', 'success'); } async function configureNanahManagedLocalNetworkProvider() { @@ -12402,6 +13126,7 @@ document.addEventListener('DOMContentLoaded', async () => { title: 'Same-Network Updates', message: 'Use this only when you have a trusted home/local gateway that can pass parent updates to protected devices on the same network.', details: [ + 'This is advanced and separate from normal Nanah live P2P.', 'Skip this for normal live P2P control.', 'Being on the same network is not enough to change rules.', 'The protected device still accepts only trusted parent updates.' @@ -12418,12 +13143,12 @@ document.addEventListener('DOMContentLoaded', async () => { endpointHost: '' }); await refreshProfilesUI(); - UIComponents.showToast('Managed local-network delivery disabled', 'success'); + UIComponents.showToast('Same-network updates disabled', 'success'); return; } const endpoint = await showPromptModal({ title: 'Same-Network Gateway', - message: 'Enter the trusted local gateway endpoint. Leave this unset unless you run a FilterTube-compatible gateway.', + message: 'Advanced only. Enter a trusted local gateway endpoint only if you run a FilterTube-compatible gateway. Normal parent control uses live P2P.', placeholder: 'http://192.168.1.10:4177/filtertube', inputType: 'url', confirmText: currentEndpoint ? 'Save Gateway' : 'Enable Same-Network Updates', @@ -12459,7 +13184,7 @@ document.addEventListener('DOMContentLoaded', async () => { ? client.createProvider(nextConfig) : null; if (!provider || provider.configured !== true || !hasNanahManagedLocalNetworkDeliveryWriter(provider)) { - UIComponents.showToast('LAN endpoint must be HTTPS or private/local HTTP and supported by the local-network provider client', 'error'); + UIComponents.showToast('Same-network endpoint must be HTTPS or private/local HTTP and supported by FilterTube', 'error'); return; } writeNanahManagedLocalNetworkProviderConfig(nextConfig); @@ -12468,7 +13193,7 @@ document.addEventListener('DOMContentLoaded', async () => { endpointHost: getManagedLocalNetworkEndpointHostFromConfig(nextConfig) }); await refreshProfilesUI(); - UIComponents.showToast('Managed local-network provider saved', 'success'); + UIComponents.showToast('Same-network updates saved', 'success'); } function hasNanahManagedMailboxUploadWriter(provider = getNanahManagedMailboxProvider()) { @@ -12622,7 +13347,7 @@ document.addEventListener('DOMContentLoaded', async () => { return { label: transports ? `${targetCount} verified device${targetCount === 1 ? '' : 's'} | ${transports} ready` - : `${targetCount} verified device${targetCount === 1 ? '' : 's'} | provider pending`, + : `${targetCount} verified device${targetCount === 1 ? '' : 's'} | open both devices`, targetCount, readyCount, revokedCount, @@ -19113,6 +19838,48 @@ document.addEventListener('DOMContentLoaded', async () => { }); } + if (ftDownloadRuleListTemplateBtn) { + ftDownloadRuleListTemplateBtn.addEventListener('click', () => { + downloadManagedRuleListCsvTemplate(); + }); + } + + if (ftDownloadRuleListJsonTemplateBtn) { + ftDownloadRuleListJsonTemplateBtn.addEventListener('click', () => { + downloadManagedRuleListJsonTemplate(); + }); + } + + if (ftImportRuleListBtn) { + ftImportRuleListBtn.addEventListener('click', async () => { + if (isUiLocked()) return; + await importManagedRuleListToActiveProfileSurfaces(getSettingsRuleListImportSurfaces()); + }); + } + + if (ftRuleListFormatsBtn) { + ftRuleListFormatsBtn.addEventListener('click', async () => { + const action = await showChoiceModal({ + title: 'Supported Rule List Formats', + message: 'Rule lists add channels and keywords only. Full FilterTube backups and legacy BlockTube export migration still belong under Choose JSON.', + details: [ + 'CSV: channel_id,keyword,notes or type,value,notes.', + 'Text: bare rows are channels; typed rows can use channel: @SomeChannel or keyword: brainrot.', + 'Rule-list JSON: channels and keywords arrays.', + 'BlockTube JSON: filterData channel/title arrays are supported here for previewed rule-list import too.', + 'Public URLs: raw HTTPS CSV, text, or JSON files can be loaded into the preview.' + ], + choices: [ + { value: 'csv-template', label: 'Download CSV Template', className: 'btn-primary' }, + { value: 'json-template', label: 'Download JSON Template', className: 'btn-secondary' } + ], + cancelText: 'Close' + }); + if (action === 'csv-template') downloadManagedRuleListCsvTemplate(); + if (action === 'json-template') downloadManagedRuleListJsonTemplate(); + }); + } + if (!subscriptionsImportFlowConsumed) { const flow = normalizeString(new URLSearchParams(window.location.search || '').get('flow')).toLowerCase(); const section = normalizeString(new URLSearchParams(window.location.search || '').get('section')).toLowerCase(); diff --git a/tests/runtime/managed-parent-ui-surface-current-behavior.test.mjs b/tests/runtime/managed-parent-ui-surface-current-behavior.test.mjs index e9757fa3..a43325cf 100644 --- a/tests/runtime/managed-parent-ui-surface-current-behavior.test.mjs +++ b/tests/runtime/managed-parent-ui-surface-current-behavior.test.mjs @@ -289,7 +289,7 @@ function buildManagedCommandCenterSummary(profilesV4, { revealDetails = false } actionIntents: [ { action: 'edit_rules', - label: 'Edit Rules', + label: 'Rules', profileId, scope: 'main_kids', authority: 'delegated_runtime_gate', @@ -305,7 +305,7 @@ function buildManagedCommandCenterSummary(profilesV4, { revealDetails = false } }, { action: timeLimit === 'No limit' ? 'set_time_limit' : 'change_time_limit', - label: timeLimit === 'No limit' ? 'Set Limit' : 'Change Limit', + label: timeLimit === 'No limit' ? 'Set Time' : 'Change Time', profileId, scope: 'time_limits', authority: 'delegated_runtime_gate', @@ -393,8 +393,8 @@ test('managed parent UI surface docs and runtime binding are linked', () => { assert.match(helperSource, /Pick a profile, set what it can watch and for how long, then send only when another device needs the update/); assert.match(helperSource, /Family Controls workflow/); assert.match(helperSource, /Choose profile/); - assert.match(helperSource, /Set rules and time/); - assert.match(helperSource, /Pair or send/); + assert.match(helperSource, /Set guardrails/); + assert.match(helperSource, /Sync when needed/); assert.match(helperSource, /actionIntents: buildManagedCommandCenterActionIntents\(profileId, timePolicy, \{/); assert.match(helperSource, /bulk_set_time_limit/); assert.match(helperSource, /bulk_edit_rules/); @@ -687,7 +687,7 @@ test('managed command-center overview aggregates parent-visible profiles without assert.deepEqual(plain(summary.rows[0].actionIntents), [ { action: 'edit_rules', - label: 'Edit Rules', + label: 'Rules', profileId: 'childA', scope: 'main_kids', authority: 'delegated_runtime_gate', @@ -703,7 +703,7 @@ test('managed command-center overview aggregates parent-visible profiles without }, { action: 'change_time_limit', - label: 'Change Limit', + label: 'Change Time', profileId: 'childA', scope: 'time_limits', authority: 'delegated_runtime_gate', @@ -1013,7 +1013,7 @@ test('managed command-center helper emits delegated action intents without polic assert.deepEqual(plain(summary.rows[0].actionIntents), [ { action: 'edit_rules', - label: 'Edit Rules', + label: 'Rules', profileId: 'childA', scope: 'main_kids', authority: 'delegated_runtime_gate', @@ -1037,7 +1037,7 @@ test('managed command-center helper emits delegated action intents without polic }, { action: 'send_managed_policy', - label: 'Send Update', + label: 'Send', profileId: 'childA', scope: 'active', authority: 'managed_policy_provider_delivery', @@ -1045,7 +1045,7 @@ test('managed command-center helper emits delegated action intents without polic }, { action: 'change_time_limit', - label: 'Change Limit', + label: 'Change Time', profileId: 'childA', scope: 'time_limits', authority: 'delegated_runtime_gate',