Merge branch 'master' into release

# Conflicts:
#	wrolpi/version.txt
This commit is contained in:
Roland 2026-07-02 17:51:52 -06:00
commit d364cf10ec
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
47 changed files with 3411 additions and 349 deletions

View file

@ -0,0 +1,29 @@
"""download_last_download_attempt
Add the `last_download_attempt` column to the `download` table. This records the last time a Download was
dispatched (whether the attempt succeeds or fails) and is used to enforce per-domain and global daily download
limits. An index supports the "attempted since local midnight" count query.
Revision ID: c3d5f7a9b1e2
Revises: b2e4f6a8c1d3
Create Date: 2026-06-28
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c3d5f7a9b1e2'
down_revision = 'b2e4f6a8c1d3'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('download', sa.Column('last_download_attempt', sa.DateTime(timezone=True), nullable=True))
op.create_index('idx_download_last_download_attempt', 'download', ['last_download_attempt'])
def downgrade():
op.drop_index('idx_download_last_download_attempt', table_name='download')
op.drop_column('download', 'last_download_attempt')

View file

@ -0,0 +1,69 @@
"""file_group_suffix
Add the `suffix` column to the `file_group` table. This stores the lowercased suffix of the primary file
(e.g. ".bin") so searches can filter by file type using an index, rather than relying on full-text tokens.
`suffix` is populated at ingest time alongside `mimetype` (wrolpi.files.lib._upsert_files and
FileGroup.from_paths). Existing rows are backfilled here using the same `split_path_stem_and_suffix` logic so
special multi-part suffixes like ".info.json" are stored correctly (not a naive ".json").
Revision ID: d4e6f8a0c2b5
Revises: c3d5f7a9b1e2
Create Date: 2026-07-01
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd4e6f8a0c2b5'
down_revision = 'c3d5f7a9b1e2'
branch_labels = None
depends_on = None
# Backfill in batches to bound memory on low-RAM devices (e.g. Raspberry Pi).
BATCH_SIZE = 5000
def upgrade():
op.add_column('file_group', sa.Column('suffix', sa.String(), nullable=True))
op.create_index('ix_file_group_suffix', 'file_group', ['suffix'])
# Backfill existing rows using the same suffix logic used at ingest time so the stored value matches what
# future refreshes would produce (including WROLPi special suffixes like ".info.json").
from wrolpi.files.lib import split_path_stem_and_suffix
conn = op.get_bind()
last_id = 0
while True:
rows = conn.execute(
sa.text('SELECT id, primary_path FROM file_group WHERE id > :last_id ORDER BY id LIMIT :limit'),
{'last_id': last_id, 'limit': BATCH_SIZE},
).fetchall()
if not rows:
break
ids, suffixes = [], []
for row_id, primary_path in rows:
suffix = (split_path_stem_and_suffix(primary_path)[1] or '').lower() or None
if suffix:
ids.append(row_id)
suffixes.append(suffix)
if ids:
conn.execute(
sa.text('''
UPDATE file_group AS fg
SET suffix = v.suffix
FROM (SELECT unnest(cast(:ids AS integer[])) AS id,
unnest(cast(:suffixes AS text[])) AS suffix) AS v
WHERE fg.id = v.id
'''),
{'ids': ids, 'suffixes': suffixes},
)
last_id = rows[-1][0]
def downgrade():
op.drop_index('ix_file_group_suffix', table_name='file_group')
op.drop_column('file_group', 'suffix')

140
app/package-lock.json generated
View file

@ -14,6 +14,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"cypress": "^13.15.2",
"esptool-js": "^0.6.0",
"lodash": "^4.18.1",
"maplibre-gl": "^5.24.0",
"mathjs": "^15.2.0",
@ -106,6 +107,7 @@
"version": "7.20.12",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz",
"integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
@ -902,6 +904,7 @@
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz",
"integrity": "sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A==",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.18.6"
},
@ -1463,6 +1466,7 @@
"version": "7.20.13",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.20.13.tgz",
"integrity": "sha512-MmTZx/bkUrfJhhYAYt3Urjm+h8DQGrPrnKQ94jLo7NLuOU+T89a7IByhKmrb8SKhrIYIQ0FN0CHMbnFRen4qNw==",
"peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.18.6",
"@babel/helper-module-imports": "^7.18.6",
@ -3377,6 +3381,7 @@
"version": "2.11.6",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@ -4164,6 +4169,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -4174,6 +4180,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@ -4297,6 +4304,7 @@
"version": "5.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.50.0.tgz",
"integrity": "sha512-vwksQWSFZiUhgq3Kv7o1Jcj0DUNylwnIlGvKvLLYsq8pAWha6/WCnXUeaSoNNha/K7QSf2+jvmkxggC1u3pIwQ==",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.50.0",
"@typescript-eslint/type-utils": "5.50.0",
@ -4348,6 +4356,7 @@
"version": "5.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.50.0.tgz",
"integrity": "sha512-KCcSyNaogUDftK2G9RXfQyOCt51uB5yqC6pkUYqhYh8Kgt+DwR5M0EwEAxGPy/+DH6hnmKeGsNhiZRQxjH71uQ==",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.50.0",
"@typescript-eslint/types": "5.50.0",
@ -4678,6 +4687,7 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4800,6 +4810,7 @@
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -5106,6 +5117,12 @@
"node": ">= 4.0.0"
}
},
"node_modules/atob-lite": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz",
"integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==",
"license": "MIT"
},
"node_modules/attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
@ -5657,6 +5674,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -6454,6 +6472,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -6740,6 +6759,7 @@
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.2.tgz",
"integrity": "sha512-ARbnUorjcCM3XiPwgHKuqsyr5W9Qn+pIIBPaoilnoBkLdSC2oLQjV1BUpnmc7KR+b7Avah3Ly2RMFnfxr96E/A==",
"hasInstallScript": true,
"peer": true,
"dependencies": {
"@cypress/request": "^3.0.6",
"@cypress/xvfb": "^1.2.4",
@ -7506,6 +7526,7 @@
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"peer": true,
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
@ -7785,6 +7806,7 @@
"version": "8.33.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.33.0.tgz",
"integrity": "sha512-WjOpFQgKK8VrCnAtl8We0SUOy/oVZ5NHykyMiagV1M9r8IFpIJX7DduK6n1mpfhlG7T1NLWm2SuD8QB7KFySaA==",
"peer": true,
"dependencies": {
"@eslint/eslintrc": "^1.4.1",
"@humanwhocodes/config-array": "^0.11.8",
@ -8198,6 +8220,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -8422,6 +8445,17 @@
"node": ">=4"
}
},
"node_modules/esptool-js": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.6.0.tgz",
"integrity": "sha512-ZyuSBXvmS+4DRKM6GrDHfCscnq3YSNlRfM+Zw0DoS7uC3SdkyC262s/ji4MjDYVmMH8SE2BiYx3/dZ1J23Xiew==",
"license": "Apache-2.0",
"dependencies": {
"atob-lite": "^2.0.0",
"pako": "^2.1.0",
"tslib": "^2.4.1"
}
},
"node_modules/esquery": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
@ -10689,6 +10723,7 @@
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
"peer": true,
"dependencies": {
"@jest/core": "^27.5.1",
"import-local": "^3.0.2",
@ -13383,6 +13418,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -13913,6 +13949,22 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@ -14223,6 +14275,7 @@
"url": "https://tidelift.com/funding/github/npm/postcss"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
@ -15281,6 +15334,7 @@
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz",
"integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -15470,6 +15524,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@ -15677,6 +15732,7 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -15830,6 +15886,7 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
@ -15931,6 +15988,7 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -16445,6 +16503,7 @@
"version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@ -16720,6 +16779,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.1.5.tgz",
"integrity": "sha512-nIqmmUNpFHfovEb+RI2w3E2/maZQutd8UIWyRjf1SLse+XF51hI559xbz/sLN3O6RpLjr/echLOOXwKCirPy3Q==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.10.5",
"@fluentui/react-component-event-listener": "~0.63.0",
@ -17807,6 +17867,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -17878,7 +17939,8 @@
"node_modules/three": {
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"peer": true
},
"node_modules/three-stdlib": {
"version": "2.17.2",
@ -18126,6 +18188,7 @@
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"peer": true,
"engines": {
"node": ">=10"
},
@ -18179,6 +18242,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -18489,6 +18553,7 @@
"version": "5.105.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
"integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@ -18558,6 +18623,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -18607,6 +18673,7 @@
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.11.1.tgz",
"integrity": "sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw==",
"peer": true,
"dependencies": {
"@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5",
@ -18661,6 +18728,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -18773,6 +18841,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -19055,6 +19124,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -19499,6 +19569,7 @@
"version": "7.20.12",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz",
"integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==",
"peer": true,
"requires": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
@ -20041,6 +20112,7 @@
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz",
"integrity": "sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A==",
"peer": true,
"requires": {
"@babel/helper-plugin-utils": "^7.18.6"
}
@ -20386,6 +20458,7 @@
"version": "7.20.13",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.20.13.tgz",
"integrity": "sha512-MmTZx/bkUrfJhhYAYt3Urjm+h8DQGrPrnKQ94jLo7NLuOU+T89a7IByhKmrb8SKhrIYIQ0FN0CHMbnFRen4qNw==",
"peer": true,
"requires": {
"@babel/helper-annotate-as-pure": "^7.18.6",
"@babel/helper-module-imports": "^7.18.6",
@ -21733,7 +21806,8 @@
"@popperjs/core": {
"version": "2.11.6",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw=="
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
"peer": true
},
"@react-three/fiber": {
"version": "8.16.5",
@ -22311,6 +22385,7 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"peer": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -22320,6 +22395,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"peer": true,
"requires": {}
},
"@types/react-reconciler": {
@ -22440,6 +22516,7 @@
"version": "5.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.50.0.tgz",
"integrity": "sha512-vwksQWSFZiUhgq3Kv7o1Jcj0DUNylwnIlGvKvLLYsq8pAWha6/WCnXUeaSoNNha/K7QSf2+jvmkxggC1u3pIwQ==",
"peer": true,
"requires": {
"@typescript-eslint/scope-manager": "5.50.0",
"@typescript-eslint/type-utils": "5.50.0",
@ -22465,6 +22542,7 @@
"version": "5.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.50.0.tgz",
"integrity": "sha512-KCcSyNaogUDftK2G9RXfQyOCt51uB5yqC6pkUYqhYh8Kgt+DwR5M0EwEAxGPy/+DH6hnmKeGsNhiZRQxjH71uQ==",
"peer": true,
"requires": {
"@typescript-eslint/scope-manager": "5.50.0",
"@typescript-eslint/types": "5.50.0",
@ -22714,7 +22792,8 @@
"acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"peer": true
},
"acorn-globals": {
"version": "6.0.0",
@ -22801,6 +22880,7 @@
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -23017,6 +23097,11 @@
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="
},
"atob-lite": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz",
"integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw=="
},
"attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
@ -23418,6 +23503,7 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"peer": true,
"requires": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -23959,6 +24045,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -24167,6 +24254,7 @@
"version": "13.15.2",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.2.tgz",
"integrity": "sha512-ARbnUorjcCM3XiPwgHKuqsyr5W9Qn+pIIBPaoilnoBkLdSC2oLQjV1BUpnmc7KR+b7Avah3Ly2RMFnfxr96E/A==",
"peer": true,
"requires": {
"@cypress/request": "^3.0.6",
"@cypress/xvfb": "^1.2.4",
@ -24730,6 +24818,7 @@
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"peer": true,
"requires": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
@ -24948,6 +25037,7 @@
"version": "8.33.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.33.0.tgz",
"integrity": "sha512-WjOpFQgKK8VrCnAtl8We0SUOy/oVZ5NHykyMiagV1M9r8IFpIJX7DduK6n1mpfhlG7T1NLWm2SuD8QB7KFySaA==",
"peer": true,
"requires": {
"@eslint/eslintrc": "^1.4.1",
"@humanwhocodes/config-array": "^0.11.8",
@ -25323,6 +25413,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -25394,6 +25485,16 @@
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esptool-js": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.6.0.tgz",
"integrity": "sha512-ZyuSBXvmS+4DRKM6GrDHfCscnq3YSNlRfM+Zw0DoS7uC3SdkyC262s/ji4MjDYVmMH8SE2BiYx3/dZ1J23Xiew==",
"requires": {
"atob-lite": "^2.0.0",
"pako": "^2.1.0",
"tslib": "^2.4.1"
}
},
"esquery": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
@ -26993,6 +27094,7 @@
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
"peer": true,
"requires": {
"@jest/core": "^27.5.1",
"import-local": "^3.0.2",
@ -28964,6 +29066,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -29331,6 +29434,11 @@
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="
},
"param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@ -29556,6 +29664,7 @@
"version": "8.4.21",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
"integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
"peer": true,
"requires": {
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
@ -30139,6 +30248,7 @@
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz",
"integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==",
"peer": true,
"requires": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -30278,6 +30388,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"peer": true,
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@ -30435,6 +30546,7 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0"
}
@ -30548,6 +30660,7 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
@ -30624,7 +30737,8 @@
"react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A=="
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
"peer": true
},
"react-router": {
"version": "7.16.0",
@ -30993,6 +31107,7 @@
"version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"peer": true,
"requires": {
"fsevents": "~2.3.2"
}
@ -31178,6 +31293,7 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.1.5.tgz",
"integrity": "sha512-nIqmmUNpFHfovEb+RI2w3E2/maZQutd8UIWyRjf1SLse+XF51hI559xbz/sLN3O6RpLjr/echLOOXwKCirPy3Q==",
"peer": true,
"requires": {
"@babel/runtime": "^7.10.5",
"@fluentui/react-component-event-listener": "~0.63.0",
@ -32007,6 +32123,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -32058,7 +32175,8 @@
"three": {
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"peer": true
},
"three-stdlib": {
"version": "2.17.2",
@ -32260,7 +32378,8 @@
"type-fest": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"peer": true
},
"type-is": {
"version": "1.6.18",
@ -32297,7 +32416,8 @@
"typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"peer": true
},
"unbox-primitive": {
"version": "1.0.2",
@ -32524,6 +32644,7 @@
"version": "5.105.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
"integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==",
"peer": true,
"requires": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@ -32556,6 +32677,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -32619,6 +32741,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -32656,6 +32779,7 @@
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.11.1.tgz",
"integrity": "sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw==",
"peer": true,
"requires": {
"@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5",
@ -32692,6 +32816,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -32938,6 +33063,7 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",

View file

@ -9,6 +9,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"cypress": "^13.15.2",
"esptool-js": "^0.6.0",
"lodash": "^4.18.1",
"maplibre-gl": "^5.24.0",
"mathjs": "^15.2.0",

View file

@ -247,11 +247,15 @@ textarea.otp {
color: #DDDDDD !important;
}
/* Direct children only, so nested components that use the `content`/`title` class names (e.g. a Message's
Message.Content) are not given the accordion's dark background. */
/* The accordion has no background of its own; it inherits whatever container it sits in (Modal
content #171616, Segment #1B1C1D, the page body, etc.). This matches how the light theme already
renders accordions (transparent), and avoids a darker band when the parent isn't #171616.
Direct children only, so nested components that use the `content`/`title` class names (e.g. a
Message's Message.Content) are not affected. */
.ui.inverted.accordion,
.ui.inverted.accordion > .title,
.ui.inverted.accordion > .content {
background-color: #171616 !important;
background-color: transparent !important;
}
/* Mobile-responsive Confirm modal */

View file

@ -25,6 +25,7 @@ import {TagsProvider} from "./Tags";
import {ZimRoute} from "./components/Zim";
import {PlaylistsRoute} from "./components/Playlists";
import {DocsRoute} from "./components/Docs";
import {FlasherRoute} from "./components/Flasher";
import ErrorBoundary from "./components/ErrorBoundary";
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
@ -107,6 +108,7 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
<Route path='zim/*' element={<ErrorBoundary><ZimRoute/></ErrorBoundary>}/>
<Route path='playlists/*' element={<ErrorBoundary><PlaylistsRoute/></ErrorBoundary>}/>
<Route path='files/*' element={<ErrorBoundary><FilesRoute/></ErrorBoundary>}/>
<Route path='flasher/*' element={<ErrorBoundary><FlasherRoute/></ErrorBoundary>}/>
</Route>));
export default function App() {

View file

@ -315,6 +315,8 @@ export async function getSettings() {
archive_destination: content.archive_destination,
download_manager_disabled: content.download_manager_disabled,
download_manager_stopped: content.download_manager_stopped,
download_daily_limit_global: content.download_daily_limit_global,
download_daily_limit_per_domain: content.download_daily_limit_per_domain,
download_on_startup: content.download_on_startup,
download_timeout: content.download_timeout,
download_wait: content.download_wait,
@ -1210,8 +1212,14 @@ export async function deleteDownload(downloadId) {
}
}
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order) {
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path) {
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
if (suffix) {
body['suffix'] = suffix;
}
if (path) {
body['path'] = path;
}
if (mimetypes) {
body['mimetypes'] = mimetypes;
}
@ -1255,6 +1263,51 @@ export async function filesSearch(offset, limit, searchStr, mimetypes, model, ta
}
}
export async function flasherSearch(chip, path) {
// Search .bin firmware, filtered to ESP images for the given chip (from connecting to the device).
const body = {};
if (chip) body['chip'] = chip;
if (path) body['path'] = path;
const response = await apiPost(`${API_URI}/flasher/search`, body);
if (response.ok) {
const data = await response.json();
return [data['file_groups'], data['totals']['file_groups']];
}
const message = await getErrorMessage(response, 'Cannot search firmware. See server logs.');
toast({type: 'error', title: 'Unable to search firmware', description: message, time: 5000});
return [null, null];
}
export async function getFlasherConfigs() {
// Return the user's saved firmware flashing configurations.
const response = await apiGet(`${API_URI}/flasher/configs`);
if (response.ok) {
const data = await response.json();
return data['configurations'] || [];
}
return [];
}
export async function saveFlasherConfig(name, files, eraseAll) {
const response = await apiPost(`${API_URI}/flasher/configs`, {name, files, erase_all: eraseAll});
if (!response.ok) {
const message = await getErrorMessage(response, 'Cannot save configuration. See server logs.');
toast({type: 'error', title: 'Unable to save configuration', description: message, time: 5000});
return false;
}
return true;
}
export async function deleteFlasherConfig(name) {
const response = await apiDelete(`${API_URI}/flasher/configs/${encodeURIComponent(name)}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Cannot delete configuration. See server logs.');
toast({type: 'error', title: 'Unable to delete configuration', description: message, time: 5000});
return false;
}
return true;
}
export async function refreshFiles(paths) {
let response;
if (Array.isArray(paths) && paths.length > 0) {

View file

@ -1,302 +1,20 @@
import React, {useContext, useState} from 'react';
import {
Checkbox,
Divider,
Icon,
Input,
Label,
Radio,
SegmentGroup,
StatisticLabel,
StatisticValue,
TableCell,
TableRow
} from "semantic-ui-react";
import React, {useContext} from 'react';
import {Input, Label, StatisticLabel, StatisticValue} from "semantic-ui-react";
import {Link, Route, Routes} from "react-router";
import {
appendChecksum,
decryptOTP,
encryptOTP,
formatMessage,
generateHtml,
OTP_CHARS,
OTP_CHARS_ALPHA,
stripChecksum,
validateCharset
} from "./otp";
import {
ErrorMessage,
humanFileSize,
humanNumber,
mimetypeColor,
PageContainer,
SimpleAccordion,
toLocaleString,
useTitle
} from "./Common";
import {ThemeContext} from "../contexts/contexts";
import {Button, Form, Header, Loader, Segment, Statistic, Table, TextArea} from "./Theme";
import {useStatistics, useVINDecoder} from "../hooks/customHooks";
import {Header, Loader, Segment, Statistic} from "./Theme";
import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
function Encrypt({chars, checksum}) {
// Encrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [plaintext, setPlaintext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setPlaintext(value);
}
};
let ciphertext = '';
let error = '';
if (otp && plaintext && chars) {
try {
const raw = encryptOTP(otp, plaintext, chars).ciphertext;
// When enabled, append a checksum character then re-group the whole message+checksum.
ciphertext = checksum ? formatMessage(appendChecksum(raw, chars)) : raw;
} catch (e) {
error = e.message;
}
}
return <>
<Header as='h2'>Encrypt</Header>
<SegmentGroup>
<Segment>
<h3>Key</h3>
<TextArea
name='otp'
className='otp'
value={otp}
onChange={handleInputChange}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
<h3>Plaintext</h3>
<TextArea
name='plaintext'
className='otp'
value={plaintext}
onChange={handleInputChange}
placeholder='The message you want to send'
/>
</Segment>
<Segment>
<h3>Ciphertext</h3>
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</>
}
function Decrypt({chars, checksum}) {
// Decrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [ciphertext, setCiphertext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setCiphertext(value);
}
};
let plaintext = '';
let error = '';
let checksumValid = null; // null = not applicable (checksum off or nothing to check)
if (otp && ciphertext && chars) {
try {
let input = ciphertext;
if (checksum) {
// Only remove the trailing character when it actually verifies as a checksum, so a message without
// one is never silently truncated.
const {body, valid} = stripChecksum(ciphertext, chars);
checksumValid = valid;
input = body;
}
plaintext = decryptOTP(otp, input, chars).plaintext;
} catch (e) {
error = e.message;
}
}
return <>
<Header as='h2'>Decrypt</Header>
<SegmentGroup>
<Segment>
<h3>Key</h3>
<TextArea
name='otp'
className='otp'
value={otp}
onChange={handleInputChange}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
<h3>Ciphertext</h3>
<TextArea
name='ciphertext'
className='otp'
value={ciphertext}
onChange={handleInputChange}
placeholder='The message you received'
/>
</Segment>
<Segment>
<h3>Plaintext</h3>
{checksumValid !== null && <p>
<Icon name={checksumValid ? 'check' : 'times'} color={checksumValid ? 'green' : 'red'}/>
{checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
</p>}
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</>
}
function OTP() {
useTitle('One Time Pad');
const {t} = useContext(ThemeContext);
// The character set is a property of the pad: encrypt, decrypt, and generation all use the same alphabet.
const [charsetKind, setCharsetKind] = useState('alphanumeric'); // 'alpha' | 'alphanumeric' | 'custom'
const [customChars, setCustomChars] = useState('');
const charsetError = charsetKind === 'custom' ? validateCharset(customChars) : null;
const chars = charsetKind === 'alpha' ? OTP_CHARS_ALPHA
: charsetKind === 'custom' ? customChars
: OTP_CHARS;
// A null charset means the custom set is not yet usable; encrypt/decrypt/generate are gated on this.
const activeChars = charsetError ? null : chars;
// Optional error-detection checksum appended to the ciphertext.
const [checksum, setChecksum] = useState(false);
let cheatSheetURL = `${process.env.PUBLIC_URL}/one-time-pad-cheat-sheet.pdf`;
// Generate a new One-Time Pad entirely in the browser and open it in a new tab so it can be printed. The pad is
// never sent to or stored on the server.
const handleGenerateNewPad = () => {
const blob = new Blob([generateHtml(activeChars)], {type: 'text/html'});
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Release the blob handle once the new tab has had time to load it.
setTimeout(() => URL.revokeObjectURL(url), 60000);
};
return <>
<Header as='h1'>One-Time Pad</Header>
<Header as='h4'>One-Time Pads can be used to encrypt your communications. This can be done by hand
(yes, really) or in this app.</Header>
<p {...t}>These messages are never stored and cannot be retrieved.</p>
<Button color='violet' disabled={!activeChars} onClick={handleGenerateNewPad}>Generate New Pad</Button>
<Button secondary href={cheatSheetURL}>Cheat Sheet PDF</Button>
<SimpleAccordion title='Advanced'>
<Header as='h4'>Pad characters</Header>
<Form>
<Form.Field>
<Radio label='A–Z' name='charset' value='alpha'
checked={charsetKind === 'alpha'} onChange={() => setCharsetKind('alpha')}/>
</Form.Field>
<Form.Field>
<Radio label='A–Z and 0–9' name='charset' value='alphanumeric'
checked={charsetKind === 'alphanumeric'} onChange={() => setCharsetKind('alphanumeric')}/>
</Form.Field>
<Form.Field>
<Radio label='Custom' name='charset' value='custom'
checked={charsetKind === 'custom'} onChange={() => setCharsetKind('custom')}/>
</Form.Field>
</Form>
{charsetKind === 'custom' && <>
<Input
fluid
value={customChars}
placeholder='e.g. ABCDEF0123'
onChange={(e, {value}) => setCustomChars(value)}
/>
{charsetError && <ErrorMessage>{charsetError}</ErrorMessage>}
</>}
<Header as='h4'>Error detection</Header>
<Checkbox
label='Append error-detection checksum'
checked={checksum}
onChange={() => setChecksum(!checksum)}
/>
</SimpleAccordion>
<Divider/>
<Encrypt chars={activeChars} checksum={checksum}/>
<Divider/>
<Decrypt chars={activeChars} checksum={checksum}/>
</>
}
function VINDecoder() {
useTitle('VIN Number Decoder');
const {t} = useContext(ThemeContext);
const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years'];
const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial'];
const {value, setValue, vin} = useVINDecoder();
console.debug(vin);
let body = <p {...t}>Enter a VIN number above</p>;
if (value && !vin) {
body = <p {...t}>VIN number is invalid</p>;
}
if (vin && vin['country']) {
let details = <p {...t}>No details</p>;
if (vin['body']) {
details = <Table celled columns={2} {...t}>
{detailsKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()] || '(Unknown)'}</TableCell>
</TableRow>)}
</Table>;
}
body = <>
<Table celled columns={2} {...t}>
{basicKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()]}</TableCell>
</TableRow>)}
</Table>
<Header as='h3'>Details</Header>
{details}
</>;
}
return <>
<Header as='h1'>VIN Number Decoder</Header>
<Input
size='large'
label='VIN'
value={value}
onChange={e => setValue(e.target.value)}
/>
<Header as='h2'>Decoded</Header>
{body}
</>
}
function StatisticsPage() {
useTitle('Statistics');
@ -423,9 +141,7 @@ export function MoreRoute(props) {
return <PageContainer>
<Routes>
<Route path='calculators' element={<CalculatorsPage/>}/>
<Route path='otp' exact element={<OTP/>}/>
<Route path='statistics' exact element={<StatisticsPage/>}/>
<Route path='vin' exact element={<VINDecoder/>}/>
</Routes>
</PageContainer>
}

View file

@ -1,6 +1,6 @@
import React from "react";
import {Breadcrumbs, useTitle} from "./Common";
import {Button, Icon} from "./Theme";
import {Button, Header, Icon} from "./Theme";
import {useCalcQuery} from "../hooks/customHooks";
import {TemperatureCalculator} from "./calculators/TemperatureCalculator";
import {ElectricalCalculators} from "./calculators/ElectricalCalculators";
@ -11,32 +11,63 @@ import {QRCodeCalculator} from "./calculators/QRCodeCalculator";
import {DriveCalculator} from "./calculators/DriveCalculator";
import {WaterCalculator} from "./calculators/WaterCalculator";
import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
// Calculators are shown in these titled groups, in this order. Each calculator carries a
// `group` matching one of these names; any calculator whose group is missing/unknown falls
// into a trailing "Other" section so a new calculator can never silently disappear.
//
// `color` is a Semantic UI named color (colorblind-friendly palette) used to tint the group's
// buttons. Violet is intentionally avoided — it is the default navbar color and would blend in.
export const CALCULATOR_GROUPS = [
{name: 'Preparedness & Storage', color: 'red'},
{name: 'Radio & Communications', color: 'blue'},
{name: 'Engineering', color: 'yellow'},
{name: 'Tools & Reference', color: 'grey'},
];
export const useCalculators = () => {
const [calc, setCalc] = useCalcQuery();
const calculators = [
{key: 'ratio', icon: 'th large', button: 'Ratio', contents: <RatioCalculators/>},
{key: 'electrical', icon: 'lightning', button: 'Electrical', contents: <ElectricalCalculators/>},
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: <DriveCalculator/>},
{key: 'water', icon: 'tint', button: 'Water Storage', contents: <WaterCalculator/>},
{key: 'ration', icon: 'food', button: 'Food Storage', contents: <FoodStorageCalculator/>},
{key: 'antenna', icon: 'signal', button: 'Antenna', contents: <DipoleAntennaCalculator/>},
{key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: <TemperatureCalculator/>},
{key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: <QRCodeCalculator/>},
{key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: <RatioCalculators/>},
{key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: <ElectricalCalculators/>},
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: <DriveCalculator/>},
{key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: <WaterCalculator/>},
{key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: <FoodStorageCalculator/>},
{key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: <DipoleAntennaCalculator/>},
{key: 'temperature', icon: 'thermometer', button: 'Temperature', group: 'Tools & Reference', contents: <TemperatureCalculator/>},
{key: 'qrCode', icon: 'qrcode', button: 'QR Code', group: 'Tools & Reference', contents: <QRCodeCalculator/>},
{key: 'otp', icon: 'lock', button: 'One Time Pad', title: 'One Time Pad', group: 'Radio & Communications', contents: <OneTimePadCalculator/>},
{key: 'vin', icon: 'car', button: 'VIN Decoder', title: 'VIN Decoder', group: 'Tools & Reference', contents: <VinDecoderCalculator/>},
];
const activeCalculator = calc ? calculators.find(i => i.key === calc) : null;
const calculatorLinks = calculators.map(i => {
const {button, icon, key} = i;
return <Link key={key} to={`/more/calculators?calc=${key}`}>
<Button key={key} style={{margin: '0.5em'}}>
const calculatorButton = ({button, icon, key}, color) =>
<Link key={key} to={`/more/calculators?calc=${key}`}>
<Button color={color} style={{margin: '0.5em'}}>
{icon && <Icon name={icon}/>}
{button}
</Button>
</Link>
});
</Link>;
// Render each defined group as a titled section, then any leftovers under "Other".
const groupNames = CALCULATOR_GROUPS.map(g => g.name);
const grouped = CALCULATOR_GROUPS.map(g => [g.name, g.color, calculators.filter(c => c.group === g.name)]);
const leftovers = calculators.filter(c => !groupNames.includes(c.group));
if (leftovers.length > 0) {
grouped.push(['Other', undefined, leftovers]);
}
const calculatorLinks = grouped
.filter(([, , items]) => items.length > 0)
.map(([name, color, items]) =>
<div key={name} style={{marginBottom: '1.5em'}}>
<Header as='h3'>{name}</Header>
{items.map(c => calculatorButton(c, color))}
</div>);
return {calc, calculators, activeCalculator, calculatorLinks}
}

View file

@ -27,7 +27,7 @@ import {useDropzone} from "react-dropzone";
import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api";
import _ from 'lodash';
import {SortableTable} from "./SortableTable";
import {useBrowseFiles, useMediaDirectory, useUploadFile, useWROLMode} from "../hooks/customHooks";
import {useBrowseFiles, useElementWidth, useMediaDirectory, useUploadFile, useWROLMode} from "../hooks/customHooks";
import {FileRowTagIcon, FilesRefreshButton} from "./Files";
import {FilePreviewContext} from "./FilePreview";
import {DragSelectionContext, DragSelectionProvider, SettingsContext, ThemeContext} from "../contexts/contexts";
@ -196,6 +196,10 @@ function SkeletonFileRow({depth}) {
);
}
// Below this footer width (px) the action buttons are icon-only; at or above it they expand to show labels.
// The eight labeled buttons need ~1020px to fit on a single row; this leaves a small buffer.
const FOOTER_LABELS_MIN_WIDTH = 1050;
export function FileBrowser() {
const [selectedPaths, setSelectedPaths] = React.useState([]);
const [renameOpen, setRenameOpen] = React.useState(false);
@ -342,9 +346,15 @@ export function FileBrowser() {
}
}
const [footerRef, footerWidth] = useElementWidth();
// Expand icon-only buttons to include labels once the footer is wide enough to fit them.
const showLabels = footerWidth >= FOOTER_LABELS_MIN_WIDTH;
// Semantic UI renders a labeled icon button when `content` and `labelPosition` are set, else icon-only.
const labelProps = (content) => showLabels ? {content, labelPosition: 'left'} : {};
const footerClassName = `sticky-footer ${inverted}`;
const footer = <div className={footerClassName}>
<FilesRefreshButton paths={selectedPaths}/>
const footer = <div className={footerClassName} ref={footerRef}>
<FilesRefreshButton paths={selectedPaths} showLabel={showLabels}/>
<APIButton
icon='trash'
color='red'
@ -353,11 +363,13 @@ export function FileBrowser() {
onClick={onDelete}
disabled={selectedPathsCount === 0}
obeyWROLMode={true}
{...labelProps('Delete')}
/>
<Button icon='text cursor'
color='yellow'
onClick={() => setRenameOpen(true)}
disabled={wrolModeEnabled || selectedPathsCount !== 1}
{...labelProps('Rename')}
/>
{selectedPathsCount === 1 ?
<RenameModal
@ -371,6 +383,7 @@ export function FileBrowser() {
color='teal'
disabled={wrolModeEnabled || selectedPathsCount === 0}
onClick={() => setMoveOpen(true)}
{...labelProps('Move')}
/>
{selectedPathsCount > 0 ?
<MoveModal open={moveOpen}
@ -382,12 +395,13 @@ export function FileBrowser() {
color='blue'
onClick={() => setMakeDirectoryOpen(true)}
disabled={wrolModeEnabled}
style={{paddingLeft: '1em', paddingRight: '0.8em'}}
style={{paddingLeft: '1em', paddingRight: showLabels ? '1em' : '0.8em'}}
>
<IconGroup>
<Icon name='folder'/>
<Icon corner name='add'/>
</IconGroup>
{showLabels ? <>&nbsp;New Folder</> : null}
</Button>
<MakeDirectoryModal
open={makeDirectoryOpen}
@ -400,6 +414,7 @@ export function FileBrowser() {
color='green'
disabled={wrolModeEnabled || (selectedPathsCount > 0 && !singleDirectorySelected)}
onClick={() => setUploadOpen(true)}
{...labelProps('Upload')}
/>
<FileBrowserUploadModal
open={uploadOpen}
@ -412,6 +427,7 @@ export function FileBrowser() {
icon={isDirectoryIgnored ? 'eye' : 'eye slash'}
disabled={!singleDirectorySelected || wrolModeEnabled}
onClick={() => setIgnoreDirectoryOpen(true)}
{...labelProps(isDirectoryIgnored ? 'Unignore' : 'Ignore')}
/>
<IgnoreDirectoryModal
open={ignoreDirectoryOpen}
@ -425,6 +441,7 @@ export function FileBrowser() {
icon='tags'
disabled={selectedPathsCount === 0}
onClick={() => setBulkTagOpen(true)}
{...labelProps('Tag')}
/>
<BulkTagModal
open={bulkTagOpen}

View file

@ -34,6 +34,7 @@ jest.mock('../hooks/customHooks', () => ({
useWROLMode: jest.fn(() => false),
useStatusFlag: jest.fn(() => false),
useUploadFile: () => mockUseUploadFile,
useElementWidth: jest.fn(() => [() => {}, 0]),
}));
// Mock react-dropzone
@ -45,7 +46,7 @@ jest.mock('react-dropzone', () => ({
}));
// Import mocked hooks for manipulation
import {useBrowseFiles, useWROLMode} from '../hooks/customHooks';
import {useBrowseFiles, useElementWidth, useWROLMode} from '../hooks/customHooks';
// Create wrapper with all required contexts
const AllContextsWrapper = ({children}) => {
@ -91,6 +92,8 @@ describe('FileBrowser', () => {
fetchFiles: jest.fn(),
});
useWROLMode.mockReturnValue(false);
// [callbackRef, width]; width 0 keeps footer buttons icon-only in tests.
useElementWidth.mockReturnValue([() => {}, 0]);
});
describe('Directory Selection', () => {

View file

@ -1132,10 +1132,12 @@ const useRefresh = () => {
}
}
export function FilesRefreshButton({paths}) {
export function FilesRefreshButton({paths, showLabel}) {
const {globalRefreshing, loading, wrolModeEnabled, refreshFiles} = useRefresh();
return <Button icon='refresh'
content={showLabel ? 'Refresh' : undefined}
labelPosition={showLabel ? 'left' : undefined}
loading={loading || globalRefreshing}
onClick={() => refreshFiles(paths)}
disabled={wrolModeEnabled || globalRefreshing}/>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,420 @@
import React from 'react';
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
// esptool-js ships untranspiled ESM which Jest will not transform; mock it so importing Flasher.js is safe.
// Real classes (not jest.fn mock impls) so `new ESPLoader()` reliably yields an instance with main()/after().
// The `mock`-prefixed holders let individual tests model different chips (esptool-js sets esploader.chip with a
// canonical CHIP_NAME plus IMAGE_CHIP_ID during main()); defaults describe an ESP32-S2.
let mockChipName = 'ESP32-S2';
let mockChipId = 2;
let mockDescription = 'ESP32-S2 (revision v0.0)';
jest.mock('esptool-js', () => ({
__esModule: true,
ESPLoader: class {
async main() {
this.chip = {CHIP_NAME: mockChipName, IMAGE_CHIP_ID: mockChipId};
return mockDescription;
}
async after() {
}
},
Transport: class {
async disconnect() {
}
},
}));
// Flasher fetches media firmware on mount; mock the API so tests don't hit the network.
jest.mock('../api', () => ({
flasherSearch: jest.fn().mockResolvedValue([[], 0]),
getFlasherConfigs: jest.fn().mockResolvedValue([]),
saveFlasherConfig: jest.fn().mockResolvedValue(true),
deleteFlasherConfig: jest.fn().mockResolvedValue(true),
}));
import {flasherSearch, getFlasherConfigs} from '../api';
import {
chipColor,
chipTextColor,
chipFamily,
chipIdName,
espImageChipId,
estimateFlashSeconds,
FlasherPage,
humanDuration,
isValidHexOffset,
parseAddress,
webSerialSupported,
withTimeout,
} from './Flasher';
describe('withTimeout', () => {
it('resolves when the promise settles before the timeout', async () => {
await expect(withTimeout(Promise.resolve('ok'), 50, 'too slow')).resolves.toBe('ok');
});
it('rejects with the message when the promise stalls (e.g. a hung connect)', async () => {
await expect(withTimeout(new Promise(() => {}), 10, 'too slow')).rejects.toThrow('too slow');
});
});
describe('estimateFlashSeconds', () => {
it('estimates from total bytes and baud rate (8N1: baud/10 bytes/sec)', () => {
// 115200 baud -> 11520 bytes/sec. 115200 bytes -> ~10s.
expect(estimateFlashSeconds(115200, 115200)).toBeCloseTo(10, 5);
// Faster baud -> proportionally less time.
expect(estimateFlashSeconds(115200, 921600)).toBeCloseTo(1.25, 5);
});
it('returns 0 for missing size or baud', () => {
expect(estimateFlashSeconds(0, 115200)).toBe(0);
expect(estimateFlashSeconds(1000, 0)).toBe(0);
});
});
describe('humanDuration', () => {
it('formats seconds and minutes', () => {
expect(humanDuration(45)).toBe('45s');
expect(humanDuration(60)).toBe('1m 0s');
expect(humanDuration(125)).toBe('2m 5s');
});
it('returns null for non-positive or invalid input', () => {
expect(humanDuration(0)).toBeNull();
expect(humanDuration(-5)).toBeNull();
expect(humanDuration(Infinity)).toBeNull();
});
});
describe('chipFamily', () => {
it('extracts the chip family from an esptool-js description', () => {
expect(chipFamily('ESP32-S2 (revision v0.0)')).toBe('ESP32-S2');
expect(chipFamily('ESP32-S3')).toBe('ESP32-S3');
expect(chipFamily('ESP32 (revision 3)')).toBe('ESP32');
});
it('returns null for empty input', () => {
expect(chipFamily('')).toBeNull();
expect(chipFamily(null)).toBeNull();
expect(chipFamily(undefined)).toBeNull();
});
});
describe('espImageChipId', () => {
// ESP image header: magic 0xE9 at byte 0, chip_id as uint16 LE at offset 0x0C.
const header = (magic, chipId) => {
const h = new Uint8Array(24);
h[0] = magic;
h[12] = chipId & 0xFF;
h[13] = (chipId >> 8) & 0xFF;
return h;
};
it('reads the chip_id from an ESP image header', () => {
expect(espImageChipId(header(0xE9, 0))).toBe(0); // ESP32
expect(espImageChipId(header(0xE9, 2))).toBe(2); // ESP32-S2
expect(espImageChipId(header(0xE9, 9))).toBe(9); // ESP32-S3
});
it('returns null for non-ESP-image files (no chip_id to compare)', () => {
expect(espImageChipId(header(0xAA, 0))).toBeNull(); // partition table magic
expect(espImageChipId(new Uint8Array([0x00, 0x01]))).toBeNull(); // too short / raw (boot_app0)
expect(espImageChipId(null)).toBeNull();
});
});
describe('chipColor', () => {
it('is deterministic — the same chip always gets the same color', () => {
expect(chipColor('ESP32')).toBe(chipColor('ESP32'));
expect(chipColor('ESP32-S3')).toBe(chipColor('ESP32-S3'));
});
it('gives every same-width chip a distinct color (the colorblind aid)', () => {
// All the 8-character ESP32-* names render at the same width, so they must never share a color.
const sameWidth = ['ESP32-S2', 'ESP32-S3', 'ESP32-C2', 'ESP32-C3',
'ESP32-C5', 'ESP32-C6', 'ESP32-H2', 'ESP32-P4'];
const colors = sameWidth.map(chipColor);
expect(new Set(colors).size).toBe(sameWidth.length);
});
it('may reuse a color across different widths (width itself disambiguates)', () => {
// ESP32 (5 chars) and a same-index 8-char chip can share a color — different widths tell them apart.
expect(chipColor('ESP32')).toBe(chipColor('ESP32-C2'));
});
it('returns a valid palette color for known, unknown, and empty names', () => {
const hex = /^#[0-9a-f]{6}$/i;
expect(chipColor('ESP32')).toMatch(hex);
expect(chipColor('ESP32-FUTURE')).toMatch(hex); // unknown -> stable hash
expect(chipColor('ESP32-FUTURE')).toBe(chipColor('ESP32-FUTURE'));
expect(chipColor(null)).toMatch(hex);
});
});
describe('chipTextColor', () => {
it('uses white text on dark backgrounds and black on light ones', () => {
expect(chipTextColor('#000000')).toBe('#fff'); // black bg
expect(chipTextColor('#4477aa')).toBe('#fff'); // Tol blue (dark)
expect(chipTextColor('#ccbb44')).toBe('#000'); // Tol yellow (light)
expect(chipTextColor('#bbbbbb')).toBe('#000'); // grey (light)
});
});
describe('chipIdName', () => {
it('maps known chip ids to family names', () => {
expect(chipIdName(0)).toBe('ESP32');
expect(chipIdName(2)).toBe('ESP32-S2');
expect(chipIdName(9)).toBe('ESP32-S3');
// Must match the backend ESP_CHIP_IDS map: 17 -> C5, 18 -> P4 (not the reverse).
expect(chipIdName(17)).toBe('ESP32-C5');
expect(chipIdName(18)).toBe('ESP32-P4');
});
it('falls back to a readable string for unknown ids', () => {
expect(chipIdName(99)).toBe('chip id 99');
});
});
describe('isValidHexOffset', () => {
it('accepts 0x-prefixed hex strings', () => {
expect(isValidHexOffset('0x0')).toBe(true);
expect(isValidHexOffset('0x10000')).toBe(true);
expect(isValidHexOffset('0XABCDEF')).toBe(true);
expect(isValidHexOffset(' 0x8000 ')).toBe(true);
});
it('rejects non-hex, un-prefixed, and empty values', () => {
expect(isValidHexOffset('')).toBe(false);
expect(isValidHexOffset(' ')).toBe(false);
expect(isValidHexOffset('10000')).toBe(false); // missing 0x prefix
expect(isValidHexOffset('0x')).toBe(false); // no digits
expect(isValidHexOffset('0xZZ')).toBe(false); // not hex
expect(isValidHexOffset('nope')).toBe(false);
expect(isValidHexOffset('0x1000 extra')).toBe(false);
});
});
describe('parseAddress', () => {
it('parses valid hex offsets', () => {
expect(parseAddress('0x0')).toBe(0);
expect(parseAddress('0x1000')).toBe(0x1000);
expect(parseAddress('0X10000')).toBe(0x10000);
expect(parseAddress(' 0x8000 ')).toBe(0x8000);
});
it('rejects empty offsets', () => {
expect(() => parseAddress('')).toThrow(/required/);
expect(() => parseAddress(' ')).toThrow(/required/);
});
it('rejects non-hex offsets', () => {
expect(() => parseAddress('nope')).toThrow(/Invalid/);
expect(() => parseAddress('0xZZ')).toThrow(/Invalid/);
expect(() => parseAddress('65536')).toThrow(/Invalid/); // decimal is no longer accepted
});
});
describe('webSerialSupported', () => {
const original = Object.getOwnPropertyDescriptor(global.navigator, 'serial');
afterEach(() => {
if (original) {
Object.defineProperty(global.navigator, 'serial', original);
} else {
delete global.navigator.serial;
}
});
it('is false when navigator.serial is missing', () => {
delete global.navigator.serial;
expect(webSerialSupported()).toBe(false);
});
it('is true when navigator.serial is present', () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
expect(webSerialSupported()).toBe(true);
});
});
// The firmware sources live behind three tabs; click a tab's menu item to reveal its pane. PageContainer renders
// both breakpoints (fresnel Media), so the menu item appears twice — clicking the first is enough.
function switchTab(name) {
fireEvent.click(screen.getAllByText(name)[0]);
}
describe('FlasherPage', () => {
const original = Object.getOwnPropertyDescriptor(global.navigator, 'serial');
beforeEach(() => {
flasherSearch.mockClear();
flasherSearch.mockResolvedValue([[], 0]);
getFlasherConfigs.mockClear();
getFlasherConfigs.mockResolvedValue([]);
// Default the mocked chip back to ESP32-S2 for each test.
mockChipName = 'ESP32-S2';
mockChipId = 2;
mockDescription = 'ESP32-S2 (revision v0.0)';
});
afterEach(() => {
if (original) {
Object.defineProperty(global.navigator, 'serial', original);
} else {
delete global.navigator.serial;
}
});
// PageContainer renders both the mobile and desktop breakpoints (fresnel Media), so each label appears twice.
it('warns when Web Serial is unavailable', () => {
delete global.navigator.serial;
render(<FlasherPage/>);
expect(screen.getAllByText('Web Serial is not available').length).toBeGreaterThan(0);
// The flashing controls must not render on an unsupported browser.
expect(screen.queryByText('Connect Device')).not.toBeInTheDocument();
});
it('renders the flasher controls when Web Serial is available', () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
render(<FlasherPage/>);
expect(screen.getAllByText('Connect Device').length).toBeGreaterThan(0);
// "Add from computer" is now a non-default tab; switch to it to reveal the dropzone.
switchTab('Add from computer');
expect(screen.getAllByText(/Click here/).length).toBeGreaterThan(0);
// Flash button is disabled until a device is connected and a file is added.
expect(screen.getAllByText('Flash Device')[0].closest('button')).toBeDisabled();
});
it('fetches media firmware on load and lists it with chip/kind in a table', async () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
flasherSearch.mockResolvedValue([[
{primary_path: 'software/MALVEKE.ino.bin', name: 'MALVEKE.ino.bin', size: 1024,
esp_chip: 'ESP32', esp_kind: 'app'},
], 1]);
render(<FlasherPage/>);
// Media firmware is fetched on mount via the flasher search (no chip filter, no path).
await waitFor(() => expect(flasherSearch).toHaveBeenCalledWith(null, null));
// The result is listed for the user to add, under the "Choose from your WROLPi" tab.
switchTab('Choose from your WROLPi');
expect((await screen.findAllByText('MALVEKE.ino.bin')).length).toBeGreaterThan(0);
// The table surfaces the detected chip and kind.
expect(screen.getAllByText('ESP32').length).toBeGreaterThan(0);
expect(screen.getAllByText('app').length).toBeGreaterThan(0);
});
it('offers the detect-device filter button', () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
render(<FlasherPage/>);
switchTab('Choose from your WROLPi');
expect(screen.getAllByText('Filter files by detecting device').length).toBeGreaterThan(0);
});
it('lists saved firmware configurations fetched on load', async () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
getFlasherConfigs.mockResolvedValue([
{name: 'T-Deck MUI', erase_all: true, files: [{path: 'a.bin', address: '0x0'}]},
]);
render(<FlasherPage/>);
await waitFor(() => expect(getFlasherConfigs).toHaveBeenCalled());
// "Saved Firmwares" is always visible as a tab label; its contents appear once the tab is active.
expect((await screen.findAllByText('Saved Firmwares')).length).toBeGreaterThan(0);
switchTab('Saved Firmwares');
expect((await screen.findAllByText('T-Deck MUI')).length).toBeGreaterThan(0);
});
it('reminds the user about boot/download mode when connecting fails', async () => {
// A non-cancel connect failure (e.g. board not in download mode) should surface the reminder.
Object.defineProperty(global.navigator, 'serial',
{value: {requestPort: jest.fn().mockRejectedValue(new Error('Failed to connect'))}, configurable: true});
render(<FlasherPage/>);
fireEvent.click(screen.getAllByText('Connect Device')[0].closest('button'));
expect((await screen.findAllByText(/download mode/i)).length).toBeGreaterThan(0);
expect((await screen.findAllByText(/Could not connect to the device/i)).length).toBeGreaterThan(0);
});
it('does not show the boot-mode reminder when the user cancels the port picker', async () => {
// Cancelling the browser picker throws NotFoundError, which is not a real failure.
const notFound = Object.assign(new Error('No port selected'), {name: 'NotFoundError'});
Object.defineProperty(global.navigator, 'serial',
{value: {requestPort: jest.fn().mockRejectedValue(notFound)}, configurable: true});
render(<FlasherPage/>);
fireEvent.click(screen.getAllByText('Connect Device')[0].closest('button'));
await waitFor(() => expect(navigator.serial.requestPort).toHaveBeenCalled());
expect(screen.queryByText(/download mode/i)).not.toBeInTheDocument();
});
it('detects the device and filters firmware by its chip', async () => {
Object.defineProperty(global.navigator, 'serial',
{value: {requestPort: jest.fn().mockResolvedValue({})}, configurable: true});
flasherSearch.mockResolvedValue([[
{primary_path: 'software/s2.bin', name: 's2.bin', size: 1024, esp_chip: 'ESP32-S2', esp_kind: 'app'},
], 1]);
render(<FlasherPage/>);
switchTab('Choose from your WROLPi');
fireEvent.click(screen.getAllByText('Filter files by detecting device')[0].closest('button'));
// The detected chip (ESP32-S2, parsed from the esptool description) is used to filter firmware.
await waitFor(() => expect(flasherSearch).toHaveBeenCalledWith('ESP32-S2', null));
// The active filter is shown, and only the matching firmware is listed.
expect((await screen.findAllByText('ESP32-S2', {exact: false})).length).toBeGreaterThan(0);
expect((await screen.findAllByText('s2.bin')).length).toBeGreaterThan(0);
});
it('filters by the canonical chip name, not the variant from the chip description', async () => {
// An ESP32-classic reports description "ESP32-D0WD-V3 (revision 3)" but CHIP_NAME "ESP32". The backend
// stores firmware chips as the canonical name ("ESP32"), so the search must use CHIP_NAME — searching the
// variant "ESP32-D0WD-V3" would match nothing even when valid firmware exists.
mockChipName = 'ESP32';
mockChipId = 0;
mockDescription = 'ESP32-D0WD-V3 (revision 3)';
Object.defineProperty(global.navigator, 'serial',
{value: {requestPort: jest.fn().mockResolvedValue({})}, configurable: true});
render(<FlasherPage/>);
switchTab('Choose from your WROLPi');
fireEvent.click(screen.getAllByText('Filter files by detecting device')[0].closest('button'));
// (flasherSearch also runs on mount with no chip; wait for the detect-driven call specifically.)
await waitFor(() => expect(flasherSearch).toHaveBeenCalledWith('ESP32', null));
});
it('adds dropped .bin files and switches to the Add-from-computer tab', async () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
render(<FlasherPage/>);
// Default tab is "Saved Firmwares"; the computer dropzone is not shown yet.
expect(screen.queryByText(/Click here/)).not.toBeInTheDocument();
const binFile = new File([new Uint8Array([0xe9, 0, 0])], 'dropped.bin');
const txtFile = new File(['nope'], 'notes.txt');
// The dropzone root wraps the whole page; a drop on any descendant bubbles up to it. react-dropzone
// requires dataTransfer.types to include 'Files' before it reads the dropped files.
const target = screen.getAllByText('1. Firmware files')[0];
fireEvent.drop(target, {dataTransfer: {files: [binFile, txtFile], types: ['Files']}});
// The .bin is added (shown in the selected-firmware table) and the .txt is ignored.
expect((await screen.findAllByText('dropped.bin')).length).toBeGreaterThan(0);
expect(screen.queryByText('notes.txt')).not.toBeInTheDocument();
// The drop switches to the "Add from computer" tab, revealing its dropzone.
expect(screen.getAllByText(/Click here/).length).toBeGreaterThan(0);
});
it('disconnects when the selected firmware set changes, forcing a fresh chip check', async () => {
Object.defineProperty(global.navigator, 'serial',
{value: {requestPort: jest.fn().mockResolvedValue({})}, configurable: true});
render(<FlasherPage/>);
// Connect to the device.
fireEvent.click(screen.getAllByText('Connect Device')[0].closest('button'));
await waitFor(() => expect(screen.getAllByText('Disconnect').length).toBeGreaterThan(0));
// Changing the firmware selection (drop a new file) must drop the connection so it is re-checked.
const bin = new File([new Uint8Array([0xe9, 0, 0])], 'swapped.bin');
fireEvent.drop(screen.getAllByText('1. Firmware files')[0],
{dataTransfer: {files: [bin], types: ['Files']}});
await waitFor(() => expect(screen.getAllByText('Connect Device').length).toBeGreaterThan(0));
expect(screen.queryByText('Disconnect')).not.toBeInTheDocument();
});
});

View file

@ -56,9 +56,8 @@ const allLinks = [
{text: 'Playlists', to: '/playlists', key: 'playlists'},
{text: 'Zim', to: '/zim', key: 'zim'},
{text: 'Inventory', to: '/inventory', key: 'inventory'},
{to: '/more/otp', text: 'One Time Pad', key: 'otp', end: true},
{to: '/more/vin', text: 'VIN Decoder', key: 'vin', end: true},
{to: '/more/calculators', text: 'Calculators', key: 'calculators', end: true},
{text: 'Flasher', to: '/flasher', key: 'flasher'},
{to: '/more/statistics', text: 'Statistics', key: 'statistics', end: true},
];

View file

@ -29,9 +29,13 @@ const SUGGESTED_APPS = [
{location: '/inventory', title: 'Inventory', description: 'Track and organize your food storage'},
{location: '/map', title: 'Map', description: 'View your Map'},
{location: '/map/manage', title: 'Manage Map', description: 'Manage your Map'},
{location: '/more/otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
{location: '/more/calculators?calc=otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
{location: '/more/statistics', title: 'Statistics', description: 'View the statistics of your WROLPi'},
{location: '/more/vin', title: 'Vin Decoder', description: 'Decode and analyze vehicle VIN numbers'},
{
location: '/more/calculators?calc=vin',
title: 'Vin Decoder',
description: 'Decode and analyze vehicle VIN numbers'
},
{location: '/videos', title: 'Videos', description: 'View your Videos'},
{location: '/videos/channel', title: 'Channels', description: 'View the Channels of your Videos'},
{location: '/zim', title: 'Zim', description: 'View your Zims'},

View file

@ -101,7 +101,7 @@ export const validUrlRegex = /^(http|https):\/\/[^ "]+$/;
export const downloadOrderOptions = [
{key: 'newest', text: 'Newest', value: 'newest'},
{key: 'oldest', text: 'Oldest', value: 'oldest'},
{key: 'views', text: 'Most Views', value: 'views'},
// 'views'/'Most Views' was removed: YouTube no longer provides view counts in channel/playlist listings.
];
export const downloadResolutionOptions = [

View file

@ -367,6 +367,13 @@ export function SettingsPage() {
setPendingSave(true);
newSettings.download_timeout = parseInt(newSettings.download_timeout);
newSettings.download_wait = parseInt(newSettings.download_wait);
// Empty daily-limit fields become 0 (unlimited); a number is sent as-is. Sending 0 rather than
// null matters because the settings API drops null fields, which would leave a previously-saved
// limit active when the user clears the field.
newSettings.download_daily_limit_per_domain = newSettings.download_daily_limit_per_domain === ''
? 0 : parseInt(newSettings.download_daily_limit_per_domain);
newSettings.download_daily_limit_global = newSettings.download_daily_limit_global === ''
? 0 : parseInt(newSettings.download_daily_limit_global);
newSettings.log_level = toApiLogLevel(newSettings.log_level);
try {
await saveSettings(newSettings);
@ -456,6 +463,8 @@ export function SettingsPage() {
require_media_mounted: settings.require_media_mounted,
download_timeout: settings.download_timeout,
download_wait: settings.download_wait,
download_daily_limit_per_domain: settings.download_daily_limit_per_domain ?? '',
download_daily_limit_global: settings.download_daily_limit_global ?? '',
download_window_start: settings.download_window_start || '',
download_window_end: settings.download_window_end || '',
timezone: settings.timezone || '',
@ -726,6 +735,34 @@ export function SettingsPage() {
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Daily Download Limit (per domain)</b>
<InfoPopup
content='Maximum number of downloads from any single domain per day. Recurring
Channel/RSS catalog updates do not count; only the videos/archives they create
and one-off downloads count. Leave empty or set to 0 for no limit.'/>
</>}
value={state.download_daily_limit_per_domain}
disabled={disabled}
onChange={(e, i) => handleTimeoutChange(e, 'download_daily_limit_per_domain', i.value)}
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Daily Download Limit (total)</b>
<InfoPopup
content='Maximum number of downloads across all domains per day. Recurring
Channel/RSS catalog updates do not count. Leave empty or set to 0 for no limit.'/>
</>}
value={state.download_daily_limit_global}
disabled={disabled}
onChange={(e, i) => handleTimeoutChange(e, 'download_daily_limit_global', i.value)}
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>

View file

@ -0,0 +1,221 @@
import React, {useContext, useState} from 'react';
import {Checkbox, Divider, Icon, Input, Radio, SegmentGroup} from "semantic-ui-react";
import {
appendChecksum,
decryptOTP,
encryptOTP,
formatMessage,
generateHtml,
OTP_CHARS,
OTP_CHARS_ALPHA,
stripChecksum,
validateCharset
} from "../otp";
import {ErrorMessage, SimpleAccordion} from "../Common";
import {ThemeContext} from "../../contexts/contexts";
import {Button, Form, Header, Segment, TextArea} from "../Theme";
function Encrypt({chars, checksum}) {
// Encrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [plaintext, setPlaintext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setPlaintext(value);
}
};
let ciphertext = '';
let error = '';
if (otp && plaintext && chars) {
try {
const raw = encryptOTP(otp, plaintext, chars).ciphertext;
// When enabled, append a checksum character then re-group the whole message+checksum.
ciphertext = checksum ? formatMessage(appendChecksum(raw, chars)) : raw;
} catch (e) {
error = e.message;
}
}
return <>
<Header as='h2'>Encrypt</Header>
<SegmentGroup>
<Segment>
<h3>Key</h3>
<TextArea
name='otp'
className='otp'
value={otp}
onChange={handleInputChange}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
<h3>Plaintext</h3>
<TextArea
name='plaintext'
className='otp'
value={plaintext}
onChange={handleInputChange}
placeholder='The message you want to send'
/>
</Segment>
<Segment>
<h3>Ciphertext</h3>
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</>
}
function Decrypt({chars, checksum}) {
// Decrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [ciphertext, setCiphertext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setCiphertext(value);
}
};
let plaintext = '';
let error = '';
let checksumValid = null; // null = not applicable (checksum off or nothing to check)
if (otp && ciphertext && chars) {
try {
let input = ciphertext;
if (checksum) {
// Only remove the trailing character when it actually verifies as a checksum, so a message without
// one is never silently truncated.
const {body, valid} = stripChecksum(ciphertext, chars);
checksumValid = valid;
input = body;
}
plaintext = decryptOTP(otp, input, chars).plaintext;
} catch (e) {
error = e.message;
}
}
return <>
<Header as='h2'>Decrypt</Header>
<SegmentGroup>
<Segment>
<h3>Key</h3>
<TextArea
name='otp'
className='otp'
value={otp}
onChange={handleInputChange}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
<h3>Ciphertext</h3>
<TextArea
name='ciphertext'
className='otp'
value={ciphertext}
onChange={handleInputChange}
placeholder='The message you received'
/>
</Segment>
<Segment>
<h3>Plaintext</h3>
{checksumValid !== null && <p>
<Icon name={checksumValid ? 'check' : 'times'} color={checksumValid ? 'green' : 'red'}/>
{checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
</p>}
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</>
}
export function OneTimePadCalculator() {
const {t} = useContext(ThemeContext);
// The character set is a property of the pad: encrypt, decrypt, and generation all use the same alphabet.
const [charsetKind, setCharsetKind] = useState('alphanumeric'); // 'alpha' | 'alphanumeric' | 'custom'
const [customChars, setCustomChars] = useState('');
const charsetError = charsetKind === 'custom' ? validateCharset(customChars) : null;
const chars = charsetKind === 'alpha' ? OTP_CHARS_ALPHA
: charsetKind === 'custom' ? customChars
: OTP_CHARS;
// A null charset means the custom set is not yet usable; encrypt/decrypt/generate are gated on this.
const activeChars = charsetError ? null : chars;
// Optional error-detection checksum appended to the ciphertext.
const [checksum, setChecksum] = useState(false);
let cheatSheetURL = `${process.env.PUBLIC_URL}/one-time-pad-cheat-sheet.pdf`;
// Generate a new One-Time Pad entirely in the browser and open it in a new tab so it can be printed. The pad is
// never sent to or stored on the server.
const handleGenerateNewPad = () => {
const blob = new Blob([generateHtml(activeChars)], {type: 'text/html'});
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Release the blob handle once the new tab has had time to load it.
setTimeout(() => URL.revokeObjectURL(url), 60000);
};
return <>
<Header as='h1'>One-Time Pad</Header>
<Header as='h4'>One-Time Pads can be used to encrypt your communications. This can be done by hand
(yes, really) or in this app.</Header>
<p {...t}>These messages are never stored and cannot be retrieved.</p>
<Button color='violet' disabled={!activeChars} onClick={handleGenerateNewPad}>Generate New Pad</Button>
<Button secondary href={cheatSheetURL}>Cheat Sheet PDF</Button>
<SimpleAccordion title='Advanced'>
<Header as='h4'>Pad characters</Header>
<Form>
<Form.Field>
<Radio label='A–Z' name='charset' value='alpha'
checked={charsetKind === 'alpha'} onChange={() => setCharsetKind('alpha')}/>
</Form.Field>
<Form.Field>
<Radio label='A–Z and 0–9' name='charset' value='alphanumeric'
checked={charsetKind === 'alphanumeric'} onChange={() => setCharsetKind('alphanumeric')}/>
</Form.Field>
<Form.Field>
<Radio label='Custom' name='charset' value='custom'
checked={charsetKind === 'custom'} onChange={() => setCharsetKind('custom')}/>
</Form.Field>
</Form>
{charsetKind === 'custom' && <>
<Input
fluid
value={customChars}
placeholder='e.g. ABCDEF0123'
onChange={(e, {value}) => setCustomChars(value)}
/>
{charsetError && <ErrorMessage>{charsetError}</ErrorMessage>}
</>}
<Header as='h4'>Error detection</Header>
<Checkbox
label='Append error-detection checksum'
checked={checksum}
onChange={() => setChecksum(!checksum)}
/>
</SimpleAccordion>
<Divider/>
<Encrypt chars={activeChars} checksum={checksum}/>
<Divider/>
<Decrypt chars={activeChars} checksum={checksum}/>
</>
}

View file

@ -0,0 +1,28 @@
import React from "react";
import {fireEvent, screen} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {OneTimePadCalculator} from "./OneTimePadCalculator";
describe('OneTimePadCalculator', () => {
test('renders the pad tools', () => {
renderWithProviders(<OneTimePadCalculator/>);
expect(screen.getByText('One-Time Pad')).toBeTruthy();
expect(screen.getByText('Generate New Pad')).toBeTruthy();
expect(screen.getByText('Encrypt')).toBeTruthy();
expect(screen.getByText('Decrypt')).toBeTruthy();
});
test('encrypts live using the entered key', () => {
const {container} = renderWithProviders(<OneTimePadCalculator/>);
// The Encrypt section's Key and Plaintext textareas are the first two on the page.
const keys = container.querySelectorAll('textarea[name="otp"]');
const plaintext = container.querySelector('textarea[name="plaintext"]');
fireEvent.change(keys[0], {target: {value: 'ABCDE'}});
fireEvent.change(plaintext, {target: {value: 'HELLO'}});
// Ciphertext is rendered into a <pre> and differs from the plaintext once a key is present.
const pres = container.querySelectorAll('pre');
const ciphertext = pres[0].textContent;
expect(ciphertext).toBeTruthy();
expect(ciphertext).not.toEqual('HELLO');
});
});

View file

@ -0,0 +1,53 @@
import React, {useContext} from 'react';
import {Input, TableCell, TableRow} from "semantic-ui-react";
import {ThemeContext} from "../../contexts/contexts";
import {useVINDecoder} from "../../hooks/customHooks";
import {Header, Table} from "../Theme";
export function VinDecoderCalculator() {
const {t} = useContext(ThemeContext);
const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years'];
const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial'];
const {value, setValue, vin} = useVINDecoder();
let body = <p {...t}>Enter a VIN number above</p>;
if (value && !vin) {
body = <p {...t}>VIN number is invalid</p>;
}
if (vin && vin['country']) {
let details = <p {...t}>No details</p>;
if (vin['body']) {
details = <Table celled columns={2} {...t}>
{detailsKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()] || '(Unknown)'}</TableCell>
</TableRow>)}
</Table>;
}
body = <>
<Table celled columns={2} {...t}>
{basicKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()]}</TableCell>
</TableRow>)}
</Table>
<Header as='h3'>Details</Header>
{details}
</>;
}
return <>
<Header as='h1'>VIN Number Decoder</Header>
<Input
size='large'
label='VIN'
value={value}
onChange={e => setValue(e.target.value)}
/>
<Header as='h2'>Decoded</Header>
{body}
</>
}

View file

@ -0,0 +1,14 @@
import React from "react";
import {screen} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {VinDecoderCalculator} from "./VinDecoderCalculator";
describe('VinDecoderCalculator', () => {
test('renders the decoder and prompts for input', () => {
// An empty VIN never triggers an API call (useVINDecoder returns early), so this
// renders without any fetch mocking.
renderWithProviders(<VinDecoderCalculator/>);
expect(screen.getByText('VIN Number Decoder')).toBeTruthy();
expect(screen.getByText('Enter a VIN number above')).toBeTruthy();
});
});

View file

@ -1,4 +1,4 @@
import React, {useContext, useEffect, useRef, useState} from "react";
import React, {useCallback, useContext, useEffect, useRef, useState} from "react";
import {
ApiDownError,
createChannel,
@ -83,6 +83,28 @@ export const useIsTouchDevice = () => {
return isTouchDevice;
};
export const useElementWidth = () => {
// Tracks the rendered width (in pixels) of an element. Returns a callback ref to attach to the element
// and its current width. Uses a callback ref (not a plain ref) so it re-attaches if the element mounts
// later (e.g. after data loads), and a ResizeObserver so the width updates as the container/viewport changes.
const [width, setWidth] = useState(0);
const observer = useRef(null);
const ref = useCallback((node) => {
if (observer.current) {
observer.current.disconnect();
observer.current = null;
}
if (node && typeof ResizeObserver === 'function') {
setWidth(node.offsetWidth);
observer.current = new ResizeObserver(() => setWidth(node.offsetWidth));
observer.current.observe(node);
}
}, []);
return [ref, width];
};
export const usePlatformModifier = () => {
// Detects if the user is on macOS to display the correct keyboard shortcut modifier.
const [isMac, setIsMac] = useState(false);

View file

55
modules/flasher/api.py Normal file
View file

@ -0,0 +1,55 @@
import asyncio
from http import HTTPStatus
from urllib.parse import unquote
from sanic import Blueprint, Request, response
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from modules.flasher import lib, schema
from modules.flasher.config import get_flasher_config
from wrolpi.api_utils import json_response
flasher_bp = Blueprint('Flasher', '/api/flasher')
@flasher_bp.post('/search')
@openapi.definition(
summary='Search .bin firmware and filter to ESP images (optionally for a specific chip)',
body=schema.FlasherSearchRequest,
)
@validate(schema.FlasherSearchRequest)
async def post_flasher_search(_: Request, body: schema.FlasherSearchRequest):
# Reading many firmware headers touches the disk; run off the event loop.
file_groups, total = await asyncio.to_thread(
lib.search_esp_firmware, body.chip, body.path, body.limit)
return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total)), HTTPStatus.OK)
@flasher_bp.get('/configs')
@openapi.definition(summary='List saved firmware flashing configurations')
async def get_flasher_configs(_: Request):
return json_response(dict(configurations=get_flasher_config().configurations), HTTPStatus.OK)
@flasher_bp.post('/configs')
@openapi.definition(
summary='Save (add or replace by name) a firmware flashing configuration',
body=schema.FlasherSaveConfigRequest,
)
@validate(schema.FlasherSaveConfigRequest)
async def save_flasher_config(_: Request, body: schema.FlasherSaveConfigRequest):
try:
configuration = get_flasher_config().save_configuration(body.name, body.files, body.erase_all)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
return json_response(dict(configuration=configuration), HTTPStatus.CREATED)
@flasher_bp.delete('/configs/<name:str>')
@openapi.definition(summary='Delete a saved firmware flashing configuration by name')
async def delete_flasher_config(_: Request, name: str):
name = unquote(name)
if get_flasher_config().delete_configuration(name):
return response.empty()
return response.json({'error': f'No saved configuration named {name!r}'}, HTTPStatus.NOT_FOUND)

86
modules/flasher/config.py Normal file
View file

@ -0,0 +1,86 @@
"""A YAML config storing the user's saved firmware flashing configurations.
Each saved configuration is a named set of firmware files (media-relative paths) and their flash offsets, plus
whether to erase the flash first. This lets a user configure a multi-part flash once (e.g. a Meshtastic T-Deck:
firmware at 0x0, littlefs at 0xc90000) and re-load it later with one click.
"""
from dataclasses import dataclass, field
from typing import List
from wrolpi.common import ConfigFile, logger
logger = logger.getChild(__name__)
@dataclass
class FlasherConfigValidator:
version: int = None
configurations: list = field(default_factory=list)
class FlasherConfig(ConfigFile):
file_name = 'flasher.yaml'
default_config = dict(
version=0,
configurations=[],
)
validator = FlasherConfigValidator
def import_config(self, file=None, send_events=False):
super().import_config(file, send_events)
# YAML-only config (no database); a successful read is a successful import.
self.successful_import = True
@property
def configurations(self) -> List[dict]:
return list(self._config.get('configurations', []))
@configurations.setter
def configurations(self, value: List[dict]):
# ConfigFile.update() validates, stores, and triggers a background save.
self.update({'configurations': value})
def save_configuration(self, name: str, files: List[dict], erase_all: bool = False) -> dict:
"""Add or replace (by name) a saved configuration. Returns the stored configuration."""
name = (name or '').strip()
if not name:
raise ValueError('Configuration name is required')
# Keep only the fields we persist for each file.
stored_files = [
dict(
path=f.get('path'),
address=f.get('address'),
name=f.get('name'),
size=f.get('size'),
)
for f in files
]
configuration = dict(name=name, erase_all=bool(erase_all), files=stored_files)
# Replace any existing configuration with the same name, then keep the list sorted by name.
configurations = [c for c in self.configurations if c.get('name') != name]
configurations.append(configuration)
configurations.sort(key=lambda c: (c.get('name') or '').lower())
self.configurations = configurations
return configuration
def delete_configuration(self, name: str) -> bool:
"""Delete a saved configuration by name. Returns True if one was removed."""
configurations = self.configurations
remaining = [c for c in configurations if c.get('name') != name]
if len(remaining) == len(configurations):
return False
self.configurations = remaining
return True
FLASHER_CONFIG: FlasherConfig = FlasherConfig()
# Test override (see get_flasher_config).
TEST_FLASHER_CONFIG = None
def get_flasher_config() -> FlasherConfig:
global TEST_FLASHER_CONFIG
if isinstance(TEST_FLASHER_CONFIG, ConfigFile):
return TEST_FLASHER_CONFIG
return FLASHER_CONFIG

View file

@ -0,0 +1,71 @@
import struct
import pytest
from modules.flasher.lib import ESP_IMAGE_MAGIC, APP_DESC_MAGIC, PARTITION_TABLE_MAGIC, read_esp_image_info
def build_esp_image(chip_id: int = 0x0000, kind: str = 'app', size: int = 2048) -> bytes:
"""Build a byte string with a realistic ESP image header for testing.
The bytes are padded with 0xFF (like real flash) and contain the magic numbers at the offsets the flasher
inspects, so tests exercise the same detection path as real firmware.
@param chip_id: The value written to the extended image header (offset 0x0C). See ESP_CHIP_IDS.
@param kind: One of:
- 'app': application image (header + esp_app_desc magic at 0x20).
- 'factory': full merged image, ESP32-S3/C3 style (bootloader header at 0x0, partition table at 0x8000).
- 'factory_s2': full merged image, ESP32/ESP32-S2 style (padding, bootloader header at 0x1000, PT at 0x8000).
- 'bootloader': a bare second-stage bootloader (header at 0x0, no app descriptor, no partition table).
- 'not_esp': not an ESP image at all (no 0xE9 magic).
"""
needs_partition_table = kind in ('factory', 'factory_s2')
if needs_partition_table:
size = max(size, 0x8100)
if kind == 'not_esp':
# Realistic non-ESP binary (e.g. a filesystem image or game data): varied bytes so it is detected as
# binary, not text. A uniform 0xFF fill is misdetected as text/plain by libmagic and breaks indexing.
return bytes((i * 7 + 13) & 0xFF for i in range(size))
# ESP images pad gaps with 0xFF like real flash; the image header makes them detect as binary.
data = bytearray(b'\xff' * size)
def write_image_header(base: int):
data[base] = ESP_IMAGE_MAGIC
data[base + 1] = 0x03 # segment_count
data[base + 2] = 0x02 # spi_mode (dio)
data[base + 3] = 0x2f # spi_speed/size
# entry_addr (4..7) left as 0xFF; chip_id is a uint16 little-endian at base + 0x0C.
struct.pack_into('<H', data, base + 0x0C, chip_id)
header_base = 0x1000 if kind == 'factory_s2' else 0x0
write_image_header(header_base)
if kind == 'app':
# esp_app_desc magic follows the image + first segment header at 0x20.
struct.pack_into('<I', data, 0x20, APP_DESC_MAGIC)
elif needs_partition_table:
# A full/merged image has the partition table at flash offset 0x8000.
data[0x8000] = PARTITION_TABLE_MAGIC[0]
data[0x8001] = PARTITION_TABLE_MAGIC[1]
return bytes(data)
@pytest.fixture
def make_esp_image(test_directory):
"""Write a semi-real ESP firmware .bin (with correct header magic numbers) into the media directory.
Usage: ``make_esp_image('software/fw.bin', chip_id=0x0002, kind='app')`` -> returns the created Path.
"""
# The header reader is lru_cached; clear it so a reused path can't return a stale result across tests.
read_esp_image_info.cache_clear()
def _make(relative_path: str, chip_id: int = 0x0000, kind: str = 'app', size: int = 2048):
path = test_directory / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(build_esp_image(chip_id=chip_id, kind=kind, size=size))
return path
return _make

135
modules/flasher/lib.py Normal file
View file

@ -0,0 +1,135 @@
"""Inspect ESP firmware images so the Flasher can filter files by the connected device's chip.
An Espressif firmware image begins with a one-byte magic (0xE9) followed by an image header. The extended
header records which chip the image was built for (``chip_id`` at offset 0x0C). We read only the first few KB
of a file to determine its target chip and what kind of image it is, without loading the whole firmware.
"""
import functools
import pathlib
from typing import List, Optional, Tuple
from wrolpi.common import get_media_directory, logger
logger = logger.getChild(__name__)
# esptool ``chip_id`` (extended image header, offset 0x0C) -> chip name. Names match esptool-js so the value
# reported by the browser after connecting can be compared directly.
ESP_CHIP_IDS = {
0x0000: 'ESP32',
0x0002: 'ESP32-S2',
0x0005: 'ESP32-C3',
0x0009: 'ESP32-S3',
0x000C: 'ESP32-C2',
0x000D: 'ESP32-C6',
0x0010: 'ESP32-H2',
0x0011: 'ESP32-C5',
0x0012: 'ESP32-P4',
}
ESP_IMAGE_MAGIC = 0xE9 # First byte of an ESP image header (app or bootloader).
APP_DESC_MAGIC = 0xABCD5432 # esp_app_desc_t magic, sits at 0x20 in an application image.
PARTITION_TABLE_MAGIC = (0xAA, 0x50) # First two bytes of a partition-table entry, always at flash offset 0x8000.
# Read enough to cover: an app/bootloader header at 0x0, the ESP32/ESP32-S2 "factory" bootloader at 0x1000, and
# the partition table at 0x8000 (which distinguishes a full/merged "factory" image from a bare app or bootloader).
_HEADER_READ_SIZE = 0x8100
def _u16(data: bytes, offset: int) -> int:
return data[offset] | (data[offset + 1] << 8)
def _u32(data: bytes, offset: int) -> int:
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)
@functools.lru_cache(maxsize=10_000)
def read_esp_image_info(path: pathlib.Path) -> dict:
"""Read an ESP firmware image's header to determine its target chip and kind.
Returns a dict: ``{'is_esp_image': bool, 'chip': str|None, 'chip_id': int|None, 'kind': str}`` where ``kind``
is one of ``app`` (application image), ``factory`` (full flashable image with a partition table),
``bootloader`` (a bare second-stage bootloader), or ``not_esp_image``.
Only the first few KB of the file are read. Cached because firmware files are immutable in practice and this
is called for many files per request.
"""
result = dict(is_esp_image=False, chip=None, chip_id=None, kind='not_esp_image')
try:
with open(path, 'rb') as fh:
head = fh.read(_HEADER_READ_SIZE)
except OSError as e:
logger.debug(f'Could not read firmware header from {path}: {e}')
return result
# The image header is at 0x0 for an app/bootloader (and for factory images on chips whose bootloader lives
# at 0x0, e.g. ESP32-S3/C3). On the ESP32 and ESP32-S2 a "factory" merged image pads 0x0..0x1000 and puts
# the bootloader header at 0x1000.
base = None
if len(head) > 0x0D and head[0] == ESP_IMAGE_MAGIC:
base = 0x0
elif len(head) > 0x100D and head[0x1000] == ESP_IMAGE_MAGIC:
base = 0x1000
if base is None:
return result
chip_id = _u16(head, base + 0x0C)
result['is_esp_image'] = True
result['chip_id'] = chip_id
result['chip'] = ESP_CHIP_IDS.get(chip_id)
# An application image carries the esp_app_desc magic right after its header (only true for app-only images;
# in a factory image offset 0x20 is bootloader/padding). A full/merged image instead has the partition
# table at 0x8000. Check app-desc first so a coincidental byte pattern at 0x8000 can't mislabel an app.
has_app_desc = len(head) >= 0x24 and _u32(head, 0x20) == APP_DESC_MAGIC
has_partition_table = (len(head) >= 0x8002
and head[0x8000] == PARTITION_TABLE_MAGIC[0]
and head[0x8001] == PARTITION_TABLE_MAGIC[1])
if has_app_desc:
result['kind'] = 'app'
elif has_partition_table:
result['kind'] = 'factory'
else:
result['kind'] = 'bootloader'
return result
def _resolve_media_path(primary_path) -> pathlib.Path:
"""Resolve a search result's primary_path (relative or absolute) to an absolute path on disk."""
path = pathlib.Path(primary_path)
return path if path.is_absolute() else get_media_directory() / path
def search_esp_firmware(chip: Optional[str] = None, path: Optional[str] = None,
limit: int = 1000) -> Tuple[List[dict], int]:
"""Search for ``.bin`` firmware, annotated with each file's detected chip/kind.
Without a ``chip`` filter every ``.bin`` is returned — including non-ESP parts (partition tables, boot_app0,
filesystem images) annotated with a null chip — so a full flash set can be assembled. With a ``chip`` filter
only ESP images built for that chip are returned.
@param chip: If provided, only return ESP images built for this chip (e.g. "ESP32-S2").
@param path: Case-insensitive partial match against the file path (same as file search).
@param limit: Maximum number of candidate .bin files to inspect.
"""
from wrolpi.files.lib import search_files
file_groups, _ = search_files(None, limit, 0, suffix='.bin', path=path)
results = []
for file_group in file_groups:
info = read_esp_image_info(_resolve_media_path(file_group['primary_path']))
if chip:
# Filtering to a specific chip: only its ESP images. Non-ESP .bin files (filesystem images, game
# installers, STM32 firmware, etc.) have no chip and are excluded from a chip-filtered search.
if not info['is_esp_image'] or info['chip'] != chip:
continue
# Unfiltered: return every .bin, annotating non-ESP parts (partition tables, boot_app0, littlefs) with a
# null chip so they remain available to assemble into a flash set.
results.append({
**file_group,
'esp_chip': info['chip'],
'esp_chip_id': info['chip_id'],
'esp_kind': info['kind'],
})
return results, len(results)

20
modules/flasher/schema.py Normal file
View file

@ -0,0 +1,20 @@
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class FlasherSearchRequest:
# Only return firmware built for this chip (e.g. "ESP32-S2"). Null returns all ESP firmware.
chip: Optional[str] = None
# Case-insensitive partial match against the file path.
path: Optional[str] = None
limit: Optional[int] = 1000
@dataclass
class FlasherSaveConfigRequest:
# The name of the saved configuration (added, or replaced if the name already exists).
name: str
# The firmware files to flash: [{path, address, name, size}, ...].
files: List[dict] = field(default_factory=list)
erase_all: bool = False

View file

View file

@ -0,0 +1,160 @@
import pathlib
from http import HTTPStatus
import pytest
from modules.flasher.lib import read_esp_image_info, search_esp_firmware
@pytest.mark.parametrize('chip_id,kind,expected_chip,expected_kind', [
(0x0000, 'app', 'ESP32', 'app'),
(0x0002, 'app', 'ESP32-S2', 'app'),
(0x0009, 'app', 'ESP32-S3', 'app'),
(0x0005, 'app', 'ESP32-C3', 'app'),
(0x000D, 'app', 'ESP32-C6', 'app'),
(0x0000, 'factory_s2', 'ESP32', 'factory'), # ESP32/S2 layout: bootloader at 0x1000
(0x0009, 'factory', 'ESP32-S3', 'factory'), # S3 layout: bootloader at 0x0, partition table at 0x8000
(0x0000, 'bootloader', 'ESP32', 'bootloader'),
])
def test_read_esp_image_info(make_esp_image, chip_id, kind, expected_chip, expected_kind):
"""The header reader identifies the target chip and image kind from realistic header bytes."""
path = make_esp_image('software/fw.bin', chip_id=chip_id, kind=kind)
info = read_esp_image_info(path)
assert info['is_esp_image'] is True
assert info['chip'] == expected_chip
assert info['chip_id'] == chip_id
assert info['kind'] == expected_kind
def test_read_esp_image_info_not_esp(make_esp_image):
"""A .bin that is not an ESP image (no 0xE9 magic) is reported as such."""
path = make_esp_image('software/data.bin', kind='not_esp')
info = read_esp_image_info(path)
assert info == dict(is_esp_image=False, chip=None, chip_id=None, kind='not_esp_image')
def test_read_esp_image_info_unknown_chip(make_esp_image):
"""An ESP image with an unrecognized chip_id is still detected as an image, with chip=None."""
path = make_esp_image('software/fw.bin', chip_id=0x7777, kind='app')
info = read_esp_image_info(path)
assert info['is_esp_image'] is True
assert info['chip'] is None
assert info['chip_id'] == 0x7777
@pytest.mark.asyncio
async def test_search_esp_firmware_filters_by_chip(async_client, test_session, make_esp_image, refresh_files):
"""search_esp_firmware returns only ESP images matching the requested chip, annotated with chip/kind."""
make_esp_image('software/esp32-app.bin', chip_id=0x0000, kind='app')
make_esp_image('software/s2-app.bin', chip_id=0x0002, kind='app')
make_esp_image('software/s3-factory.bin', chip_id=0x0009, kind='factory')
make_esp_image('software/s3-app.bin', chip_id=0x0009, kind='app')
# A non-ESP .bin (e.g. a filesystem image or game installer) must never be returned.
make_esp_image('software/littlefs.bin', kind='not_esp')
await refresh_files()
def names(results):
# search_esp_firmware (lib layer) returns primary_path as an absolute Path; compare by basename.
return sorted(pathlib.Path(r['primary_path']).name for r in results)
# Filter to ESP32-S3: only the two S3 images, never the non-ESP file.
s3, total = search_esp_firmware(chip='ESP32-S3')
assert total == 2
assert names(s3) == ['s3-app.bin', 's3-factory.bin']
assert {r['esp_chip'] for r in s3} == {'ESP32-S3'}
assert {r['esp_kind'] for r in s3} == {'app', 'factory'}
# Filter to ESP32-S2: just the one.
s2, total = search_esp_firmware(chip='ESP32-S2')
assert total == 1
assert names(s2) == ['s2-app.bin']
# No chip: every .bin, including non-ESP parts (partition tables, littlefs, boot_app0) annotated with a null
# chip so a full flash set can still be assembled from the picker.
all_bin, total = search_esp_firmware()
assert total == 5
assert 'littlefs.bin' in names(all_bin)
littlefs = next(r for r in all_bin if pathlib.Path(r['primary_path']).name == 'littlefs.bin')
assert littlefs['esp_chip'] is None
assert littlefs['esp_kind'] == 'not_esp_image'
# Filtering to a specific chip still returns only that chip's ESP images (non-ESP parts are shown unfiltered).
_, total = search_esp_firmware(chip='ESP32-H2')
assert total == 0
@pytest.mark.asyncio
async def test_flasher_search_api(async_client, test_session, make_esp_image, refresh_files):
"""The /api/flasher/search endpoint filters by chip and supports the path filter."""
make_esp_image('software/marauder/esp32.bin', chip_id=0x0000, kind='app')
make_esp_image('software/marauder/s3.bin', chip_id=0x0009, kind='app')
make_esp_image('software/other/s3.bin', chip_id=0x0009, kind='app')
await refresh_files()
# Filter by chip.
request, response = await async_client.post('/api/flasher/search', json=dict(chip='ESP32-S3'))
assert response.status_code == HTTPStatus.OK
got = sorted(fg['primary_path'] for fg in response.json['file_groups'])
assert got == ['software/marauder/s3.bin', 'software/other/s3.bin']
assert response.json['totals']['file_groups'] == 2
# Filter by chip AND path (the flasher's detect + folder filter combined).
request, response = await async_client.post('/api/flasher/search',
json=dict(chip='ESP32-S3', path='marauder'))
assert response.status_code == HTTPStatus.OK
got = [fg['primary_path'] for fg in response.json['file_groups']]
assert got == ['software/marauder/s3.bin']
assert response.json['file_groups'][0]['esp_chip'] == 'ESP32-S3'
@pytest.mark.asyncio
async def test_flasher_saved_configs_api(async_client, test_session, test_directory):
"""Saved firmware configurations can be created, listed, replaced, deleted, and are persisted to YAML."""
# Initially empty.
request, response = await async_client.get('/api/flasher/configs')
assert response.status_code == HTTPStatus.OK
assert response.json['configurations'] == []
# Save a multi-part configuration (like a Meshtastic T-Deck).
body = dict(name='T-Deck MUI', erase_all=True, files=[
dict(path='software/firmware-t-deck-tft.bin', address='0x0', name='firmware-t-deck-tft.bin', size=100),
dict(path='software/littlefs-t-deck-tft.bin', address='0xc90000', name='littlefs-t-deck-tft.bin', size=200),
])
request, response = await async_client.post('/api/flasher/configs', json=body)
assert response.status_code == HTTPStatus.CREATED
# It is listed with its files and offsets.
request, response = await async_client.get('/api/flasher/configs')
configs = response.json['configurations']
assert len(configs) == 1
assert configs[0]['name'] == 'T-Deck MUI'
assert configs[0]['erase_all'] is True
assert [f['address'] for f in configs[0]['files']] == ['0x0', '0xc90000']
# It is persisted to flasher.yaml.
assert (test_directory / 'config/flasher.yaml').is_file()
# Saving the same name replaces it (still one configuration).
body2 = dict(name='T-Deck MUI', files=[dict(path='software/other.bin', address='0x0')])
request, response = await async_client.post('/api/flasher/configs', json=body2)
assert response.status_code == HTTPStatus.CREATED
request, response = await async_client.get('/api/flasher/configs')
configs = response.json['configurations']
assert len(configs) == 1
assert [f['path'] for f in configs[0]['files']] == ['software/other.bin']
# An empty name is rejected.
request, response = await async_client.post('/api/flasher/configs', json=dict(name=' ', files=[]))
assert response.status_code == HTTPStatus.BAD_REQUEST
# Delete it (name is URL-encoded).
request, response = await async_client.delete('/api/flasher/configs/T-Deck%20MUI')
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = await async_client.get('/api/flasher/configs')
assert response.json['configurations'] == []
# Deleting a missing configuration 404s.
request, response = await async_client.delete('/api/flasher/configs/nope')
assert response.status_code == HTTPStatus.NOT_FOUND

View file

@ -23,7 +23,7 @@ from yt_dlp.extractor import YoutubeTabIE # noqa
from wrolpi.cmd import YT_DLP_BIN
from wrolpi.common import logger, get_media_directory, escape_file_name, resolve_generators, background_task, \
trim_file_name, cached_multiprocessing_result, get_absolute_media_path, aiohttp_post
trim_file_name, cached_multiprocessing_result, get_absolute_media_path, aiohttp_post, normalize_domain
from wrolpi.dates import now
from wrolpi.db import get_db_session
from wrolpi.downloader import Downloader, Download, DownloadContext, DownloadResult, \
@ -529,6 +529,10 @@ class ChannelDownloader(Downloader, ABC):
def __repr__(self):
return f'<ChannelDownloader>'
def normalize_domain(self, url: str) -> str:
# Collapse youtu.be / youtube.com/shorts / tracking params to the canonical youtube.com domain.
return normalize_domain(normalize_video_url(url))
@staticmethod
def is_a_playlist(info: dict):
# A playlist may have an id different from its channel.
@ -716,9 +720,12 @@ class ChannelDownloader(Downloader, ABC):
logger.debug(f'Downloading oldest videos from {download.url}')
downloads = downloads.reverse()
elif sort_key == 'views':
# Download videos with most views.
logger.debug(f'Downloading most viewed videos from {download.url}')
downloads = sorted(downloads, key=lambda i: i['view_count'], reverse=True)
# YouTube no longer exposes `view_count` in flat channel/playlist listings, so we can no longer
# sort by views without fetching every video individually. This may become possible again if we
# integrate the YouTube Data API.
raise UnrecoverableDownloadError(
'Downloading by "Most Views" is no longer supported because YouTube no longer provides view '
'counts in channel/playlist listings. Please change this download to "Newest" or "Oldest".')
# Limit the videos that will be downloads (this allows the user to download the "top 100" videos of a Channel)
if video_count_limit := settings.get('video_count_limit'):
@ -793,6 +800,10 @@ class VideoDownloader(Downloader, ABC):
def __repr__(self):
return f'<VideoDownloader>'
def normalize_domain(self, url: str) -> str:
# Collapse youtu.be / youtube.com/shorts / tracking params to the canonical youtube.com domain.
return normalize_domain(normalize_video_url(url))
def already_downloaded(self, session: Session, *urls: str) -> List:
# We only consider a video record with a video file as "downloaded".
file_groups = list(session.query(FileGroup).filter(FileGroup.url.in_(urls), FileGroup.model == 'video'))

View file

@ -706,6 +706,27 @@ async def test_download_playlist(test_session, test_directory, mock_video_extrac
}
def test_get_missing_videos_views_order_deprecated(test_session):
"""The 'views' download order was deprecated. YouTube no longer provides `view_count` in flat
channel/playlist listings, so a download configured for 'views' raises an unrecoverable error rather
than crashing with a KeyError or silently downloading in the wrong order."""
from modules.videos.downloader import channel_downloader
# Flat entries no longer contain a `view_count` key (matches current yt-dlp behavior).
download = Download(
url='https://www.youtube.com/@example/videos',
settings={'download_order': 'views'},
info_json={'entries': [
{'_type': 'url', 'id': 'a', 'title': 'a', 'url': 'https://youtube.com/watch?v=a'},
{'_type': 'url', 'id': 'b', 'title': 'b', 'url': 'https://youtube.com/watch?v=b'},
]},
)
test_session.add(download)
test_session.commit()
with pytest.raises(UnrecoverableDownloadError):
channel_downloader.get_missing_videos(test_session, download)
@pytest.mark.asyncio
async def test_channel_download_crud(test_session, async_client, assert_downloads, tag_factory):
"""Test creating more complex Channel Download."""

View file

@ -1068,6 +1068,8 @@ class MultiFileConfig:
class WROLPiConfigValidator:
archive_destination: str = None
check_for_upgrades: bool = None
download_daily_limit_global: int = None
download_daily_limit_per_domain: int = None
download_on_startup: bool = None
download_timeout: int = None
download_wait: int = None
@ -1139,6 +1141,10 @@ def get_all_configs() -> Dict[str, ConfigFile]:
if map_pins_config := get_map_pins_config():
all_configs[map_pins_config.file_name] = map_pins_config
from modules.flasher.config import get_flasher_config
if flasher_config := get_flasher_config():
all_configs[flasher_config.file_name] = flasher_config
from wrolpi.collections.config import get_playlists_config
if playlists_config := get_playlists_config():
all_configs[playlists_config.file_name] = playlists_config
@ -1236,6 +1242,16 @@ async def import_all_db_configs() -> dict[str, bool]:
logger.warning(f'Failed to import map pins config: {e}')
results['map_pins'] = False
# Flasher saved firmware configurations (YAML-only, no DB)
try:
from modules.flasher.config import get_flasher_config
get_flasher_config().import_config()
results['flasher'] = True
logger.debug('flasher config imported')
except Exception as e:
logger.warning(f'Failed to import flasher config: {e}')
results['flasher'] = False
return results
@ -1251,6 +1267,8 @@ class WROLPiConfig(ConfigFile):
file_name = 'wrolpi.yaml'
default_config = dict(
archive_destination='archive/%(domain_tag)s/%(domain)s',
download_daily_limit_global=None,
download_daily_limit_per_domain=None,
download_on_startup=True,
download_timeout=0,
download_wait=20,
@ -1371,6 +1389,24 @@ class WROLPiConfig(ConfigFile):
def download_wait(self, value: int):
self.update({'download_wait': value})
@property
def download_daily_limit_per_domain(self) -> Optional[int]:
"""Maximum non-recurring downloads allowed per domain per day. ``None``/``0`` means unlimited."""
return self._config.get('download_daily_limit_per_domain')
@download_daily_limit_per_domain.setter
def download_daily_limit_per_domain(self, value: Optional[int]):
self.update({'download_daily_limit_per_domain': value})
@property
def download_daily_limit_global(self) -> Optional[int]:
"""Maximum non-recurring downloads allowed across all domains per day. ``None``/``0`` means unlimited."""
return self._config.get('download_daily_limit_global')
@download_daily_limit_global.setter
def download_daily_limit_global(self, value: Optional[int]):
self.update({'download_daily_limit_global': value})
@staticmethod
def _normalize_time_value(value) -> str:
"""YAML may parse HH:MM values as sexagesimal integers (e.g. 21:00 -> 1260). Convert back to HH:MM."""
@ -1825,6 +1861,32 @@ def extract_domain(url: str) -> str:
return domain
# Common subdomains stripped when grouping downloads by domain (e.g. for daily download limits).
COMMON_DOWNLOAD_SUBDOMAINS = ('m.', 'mobile.')
def normalize_domain(url: str) -> str:
"""Return the canonical domain used to group downloads (e.g. for daily download limits).
Strips the port, a leading ``www.`` (via :func:`extract_domain`), and other common subdomains
(``m.``, ``mobile.``) so that, for example, ``m.example.com`` and ``www.example.com`` share one bucket.
Downloaders may apply type-specific URL normalization before calling this (see
``Downloader.normalize_domain``).
>>> normalize_domain('https://m.example.com/foo')
'example.com'
>>> normalize_domain('https://www.example.com:443/foo')
'example.com'
"""
domain = extract_domain(url)
for subdomain in COMMON_DOWNLOAD_SUBDOMAINS:
if domain.startswith(subdomain):
domain = domain[len(subdomain):]
break
return domain
def api_param_limiter(maximum: int, default: int = 20) -> callable:
"""Create a function which restricts the maximum number that can be returned.
Useful for restricting API limit params.

View file

@ -34,6 +34,7 @@ def attach_shared_contexts(app: Sanic):
app.shared_ctx.archive_downloader_config = manager.dict()
app.shared_ctx.download_cache_config = manager.dict()
app.shared_ctx.map_pins_config = manager.dict()
app.shared_ctx.flasher_config = manager.dict()
# Shared dicts.
app.shared_ctx.refresh = manager.dict()
app.shared_ctx.uploaded_files = manager.dict()
@ -277,3 +278,9 @@ def initialize_configs_contexts(app: Sanic):
MAP_PINS_CONFIG.initialize(app.shared_ctx.map_pins_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory map pins config: {e}')
try:
from modules.flasher.config import FLASHER_CONFIG
FLASHER_CONFIG.initialize(app.shared_ctx.flasher_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory flasher config: {e}')

View file

@ -33,7 +33,7 @@ from wrolpi.api_utils import api_app, perpetual_signal
from wrolpi.cmd import which, run_command, CommandResult
from wrolpi.common import Base, ModelHelper, logger, wrol_mode_check, zig_zag, ConfigFile, \
wrol_mode_enabled, background_task, get_absolute_media_path, timer, aiohttp_get, \
get_download_info, trim_file_name, get_wrolpi_config, TRACE_LEVEL
get_download_info, trim_file_name, get_wrolpi_config, TRACE_LEVEL, normalize_domain
from wrolpi.dates import TZDateTime, now, Seconds
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError, BotBlockedDownloadError, UnknownDownload, \
@ -241,6 +241,7 @@ class Download(ModelHelper, Base): # noqa
__tablename__ = 'download' # noqa
__table_args__ = (
Index('idx_download_collection_id', 'collection_id'),
Index('idx_download_last_download_attempt', 'last_download_attempt'),
)
id = Column(Integer, primary_key=True)
url = Column(String, nullable=False, unique=True)
@ -252,6 +253,7 @@ class Download(ModelHelper, Base): # noqa
error = Column(Text) # traceback from an error during downloading.
frequency = Column(Integer) # seconds between re-downloading.
info_json = Column(JSONB) # information retrieved WHILE downloading (from yt-dlp)
last_download_attempt = Column(TZDateTime) # last time this Download was dispatched (success or failure)
last_successful_download = Column(TZDateTime)
location = Column(Text) # Relative App URL where the downloaded item can be viewed.
next_download = Column(TZDateTime)
@ -280,6 +282,7 @@ class Download(ModelHelper, Base): # noqa
frequency=self.frequency,
destination=self.destination,
id=self.id,
last_download_attempt=self.last_download_attempt,
last_successful_download=self.last_successful_download,
location=self.location,
next_download=self.next_download,
@ -440,6 +443,14 @@ class Downloader:
def __repr__(self):
return f'<Downloader name={self.name}>'
def normalize_domain(self, url: str) -> str:
"""Return the canonical domain used to group this Download for daily download limits.
The base implementation strips common subdomains (www, m, mobile) and the port. Subclasses
may override to apply download-type-specific URL normalization before extracting the domain
(e.g. VideoDownloader collapses youtu.be and /shorts/ links to youtube.com)."""
return normalize_domain(url)
def prepare_download(self, session: Session, download: Download) -> Any:
"""DB-bound prep. Caller (DownloadManager) opens and commits the session.
@ -720,14 +731,57 @@ class DownloadManager:
self.disabled.clear()
@staticmethod
def _get_local_time():
"""Return the current local time, using configured timezone or system default."""
from wrolpi.common import get_wrolpi_config
def _get_local_now():
"""Return the current local datetime, using configured timezone or system default."""
config = get_wrolpi_config()
if config.timezone:
tz = ZoneInfo(config.timezone)
return now().astimezone(tz).time()
return now().astimezone().time()
return now().astimezone(ZoneInfo(config.timezone))
return now().astimezone()
@staticmethod
def _get_local_time():
"""Return the current local time, using configured timezone or system default."""
return DownloadManager._get_local_now().time()
@classmethod
def _get_local_day_start(cls):
"""Return the most recent local midnight as a timezone-aware datetime.
Used as the lower bound when counting downloads attempted "today" for daily limits. Comparing
against the (UTC-aware) ``last_download_attempt`` column works because both are timezone-aware."""
return cls._get_local_now().replace(hour=0, minute=0, second=0, microsecond=0)
def _normalize_download_domain(self, download: 'Download') -> str:
"""Return the normalized domain used to group a Download for daily limits.
Delegates to the Download's downloader so type-specific URL normalization applies; falls back to
the generic normalizer when no downloader is registered for the Download."""
downloader = None
if download.downloader:
try:
downloader = self.find_downloader_by_name(download.downloader)
except InvalidDownload:
downloader = None
if downloader:
return downloader.normalize_domain(download.url)
return normalize_domain(download.url)
def daily_download_counts(self, session: Session) -> Tuple[int, Dict[str, int]]:
"""Count non-recurring downloads attempted since local midnight.
Recurring downloads (those with a ``frequency``, e.g. Channel/RSS catalog updates) never count
against the limits. Returns ``(global_count, {domain: count})`` where domains are normalized."""
day_start = self._get_local_day_start()
attempted = session.query(Download).filter(
Download.frequency.is_(None),
Download.last_download_attempt.isnot(None),
Download.last_download_attempt >= day_start,
).all()
domain_counts: Dict[str, int] = {}
for download in attempted:
domain = self._normalize_download_domain(download)
domain_counts[domain] = domain_counts.get(domain, 0) + 1
return len(attempted), domain_counts
def is_within_download_window(self) -> bool:
"""Returns True if current time is within the configured download window, or no window is configured."""
@ -1019,11 +1073,42 @@ class DownloadManager:
Download.id))
count = 0
download_wait = get_wrolpi_config().download_wait
# Daily download limits. A value of None/0 means unlimited. Recurring downloads (Channel/RSS
# catalog updates) are never gated and never counted; only their non-recurring children and
# one-off downloads count.
#
# This is a best-effort soft cap, not a hard quota. The count is read from the DB and the slot is
# claimed (last_download_attempt set) inside this dispatch transaction. The steady-state dispatcher
# (perpetual_download_worker) is single-process and self-serializing, so it never races itself; but
# create_downloads fires a background dispatch_downloads() to "start ASAP", which can overlap this
# cycle in another worker and read the same pre-commit count. At a limit boundary that can leak a
# small, bounded overshoot (paced at one download per domain per cycle); it self-corrects the next
# cycle once last_download_attempt is committed and visible. Enforcing a strict cap would require
# coordinating the count in shared_ctx with cross-process locking, which is not warranted here.
limit_per_domain = get_wrolpi_config().download_daily_limit_per_domain or 0
limit_global = get_wrolpi_config().download_daily_limit_global or 0
global_count, domain_counts = 0, {}
if limit_per_domain or limit_global:
global_count, domain_counts = self.daily_download_counts(session)
rate_limited_domains = {} # Track domains we've already checked for rate limiting
limits_enabled = bool(limit_per_domain or limit_global)
for download in new_downloads:
domain = download.domain
is_recurring = download.frequency is not None
# Non-recurring downloads with limits enabled are grouped by their normalized domain.
limit_domain = self._normalize_download_domain(download) \
if (limits_enabled and not is_recurring) else None
if domain not in self.processing_domains and len(
self.processing_domains) < SIMULTANEOUS_DOWNLOAD_DOMAINS:
# Daily download limits (recurring downloads bypass the gate entirely).
if limit_domain is not None:
if limit_global and global_count >= limit_global:
# Global daily limit reached; skip all remaining counted downloads this cycle.
continue
if limit_per_domain and domain_counts.get(limit_domain, 0) >= limit_per_domain:
# This domain hit its daily limit; other domains may still have room.
continue
# Per-domain rate limiting.
if download_wait and download_wait > 0:
try:
@ -1037,6 +1122,13 @@ class DownloadManager:
except (AttributeError, RuntimeError):
pass # shared_ctx not initialized
# Record the attempt time now (in this committed session) so the next dispatch cycle
# counts it even before the download's signal handler marks it started.
download.last_download_attempt = now()
if limit_domain is not None:
global_count += 1
domain_counts[limit_domain] = domain_counts.get(limit_domain, 0) + 1
self._add_processing_domain(domain)
context = dict(download_id=download.id, download_url=download.url)
await api_app.dispatch('wrolpi.download.download', context=context)

View file

@ -165,7 +165,7 @@ async def post_search_files(_: Request, body: schema.FilesSearchRequest):
with timer('Searching all files', 'info', logger__=logger):
file_groups, total = lib.search_files(body.search_str, body.limit, body.offset, body.mimetypes, body.model,
body.tag_names, body.headline, body.months, body.from_year, body.to_year,
body.any_tag, body.order, body.url)
body.any_tag, body.order, body.url, body.suffix, body.path)
return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total)))

View file

@ -175,6 +175,9 @@ def _get_recursive_directory_dict(session: Session, directory: pathlib.Path, dir
if directory in directories:
children = dict()
for path in directory.iterdir():
if not path.is_dir() and path.name in HIDDEN_FILES:
# Never show ignored files.
continue
if path.is_dir() and path in directories:
children[f'{path.name}/'] = _get_recursive_directory_dict(session, path, directories)
elif path.is_dir():
@ -186,6 +189,8 @@ def _get_recursive_directory_dict(session: Session, directory: pathlib.Path, dir
HIDDEN_DIRECTORIES = ('lost+found',)
# macOS metadata files which should never be shown in the FileBrowser.
HIDDEN_FILES = ('.DS_Store', '._.DS_Store')
def list_directories_contents(session: Session, directories_: List[str]) -> Dict:
@ -208,6 +213,9 @@ def list_directories_contents(session: Session, directories_: List[str]) -> Dict
if path.is_dir() and path.name in HIDDEN_DIRECTORIES:
# Never show ignored directories.
continue
if not path.is_dir() and path.name in HIDDEN_FILES:
# Never show ignored files.
continue
if path.is_dir():
paths[f'{path.name}/'] = _get_recursive_directory_dict(session, path, directories)
else:
@ -700,7 +708,8 @@ def _upsert_files(files: List[pathlib.Path], idempotency: datetime.datetime,
modification_datetime = from_timestamp(max(i.stat().st_mtime for i in group))
size = sum(i.stat().st_size for i in group)
files = json.dumps(_paths_to_files_dict(group))
values[primary_path] = (modification_datetime, mimetype, size, files)
suffix = (split_path_stem_and_suffix(primary_path)[1] or '').lower() or None
values[primary_path] = (modification_datetime, mimetype, size, files, suffix)
non_primary_files = {i for i in group if i != primary_path}
except NoPrimaryFile:
# Cannot find primary path, create a `file_group` for each file.
@ -711,7 +720,8 @@ def _upsert_files(files: List[pathlib.Path], idempotency: datetime.datetime,
modification_datetime = from_timestamp(primary_path.stat().st_mtime)
size = primary_path.stat().st_size
files = json.dumps(_paths_to_files_dict([primary_path]))
values[primary_path] = (modification_datetime, mimetype, size, files)
suffix = (split_path_stem_and_suffix(primary_path)[1] or '').lower() or None
values[primary_path] = (modification_datetime, mimetype, size, files, suffix)
values = [(str(primary_path),
str(primary_path.parent), # directory
@ -721,7 +731,8 @@ def _upsert_files(files: List[pathlib.Path], idempotency: datetime.datetime,
values = mogrify(curs, values)
stmt = f'''
INSERT INTO file_group
(primary_path, directory, indexed, idempotency, modification_datetime, mimetype, size, files)
(primary_path, directory, indexed, idempotency, modification_datetime, mimetype, size, files,
suffix)
VALUES {values}
ON CONFLICT (primary_path) DO UPDATE
SET
@ -730,6 +741,8 @@ def _upsert_files(files: List[pathlib.Path], idempotency: datetime.datetime,
modification_datetime=EXCLUDED.modification_datetime,
mimetype=EXCLUDED.mimetype,
size=EXCLUDED.size,
-- Always keep suffix current so suffix search works even for already-indexed files.
suffix=EXCLUDED.suffix,
files=(
-- Do not overwrite files unless the files have changed and need to be re-indexed.
CASE
@ -1026,7 +1039,7 @@ async def search_directories_by_name(session: Session, name: str, excluded: List
def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str] = None, model: str = None,
tag_names: List[str] = None, headline: bool = False, months: List[int] = None,
from_year: int = None, to_year: int = None, any_tag: bool = False, order: str = None,
url: str = None) -> \
url: str = None, suffix: str = None, path: str = None) -> \
Tuple[List[dict], int]:
"""Search the FileGroup table.
@ -1046,6 +1059,8 @@ def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str]
@param months: A list of integers representing the index of the month of the year, starting at 1.
@param order: Used to change results from most relevant to recently viewed.
@param url: Filter by URL using case-insensitive partial match (ILIKE).
@param suffix: Only return files whose primary file has this suffix (e.g. ".bin"), case-insensitive.
@param path: Filter by primary_path using case-insensitive partial match (ILIKE).
"""
params = dict(offset=offset, limit=limit, search_str=search_str, url_search_str=f'%{search_str}%')
wheres = []
@ -1072,6 +1087,16 @@ def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str]
params['url_filter'] = f'%{url}%'
wheres.append('fg.url ILIKE %(url_filter)s')
if suffix:
# Suffix is stored lowercased with a leading dot; normalize the caller's value to match the index.
normalized = suffix if suffix.startswith('.') else f'.{suffix}'
params['suffix'] = normalized.lower()
wheres.append('fg.suffix = %(suffix)s')
if path:
params['path_filter'] = f'%{path}%'
wheres.append('fg.primary_path ILIKE %(path_filter)s')
if search_str and headline:
headline = ''',
ts_headline(fg.title, websearch_to_tsquery(%(search_str)s)) AS "title_headline",

View file

@ -104,6 +104,7 @@ class FileGroup(ModelHelper, Base):
published_datetime = Column(TZDateTime) # the date the creator published this file
published_modified_datetime = Column(TZDateTime) # the date the publisher modified this file
size = Column(BigInteger, default=lambda: 0)
suffix = Column(String, index=True) # lowercased suffix of the primary_path (e.g. ".bin"), for indexed filtering
title = Column(String) # user-displayable title
url = Column(String) # the location where this file can be downloaded.
viewed = Column(TZDateTime) # the most recent time a User viewed this file.
@ -498,7 +499,8 @@ class FileGroup(ModelHelper, Base):
@classmethod
def from_paths(cls, session: Session, *paths: pathlib.Path) -> 'FileGroup':
"""Create a new FileGroup which contains the provided file paths."""
from wrolpi.files.lib import get_primary_file, get_mimetype, sanitize_filename_surrogates
from wrolpi.files.lib import get_primary_file, get_mimetype, sanitize_filename_surrogates, \
split_path_stem_and_suffix
# Sanitize any paths with invalid UTF-8 characters (renames files on disk if needed)
paths = tuple(sanitize_filename_surrogates(p) for p in paths)
@ -533,6 +535,8 @@ class FileGroup(ModelHelper, Base):
file_group.modification_datetime = from_timestamp(max(i.stat().st_mtime for i in paths))
file_group.size = sum(i.stat().st_size for i in paths)
file_group.mimetype = get_mimetype(file_group.primary_path)
# Store the lowercased suffix so searches can filter by file type using the indexed column.
file_group.suffix = (split_path_stem_and_suffix(primary_path)[1] or '').lower() or None
logger.trace(f'FileGroup.from_paths: {file_group}')
return file_group

View file

@ -42,6 +42,8 @@ class FilesSearchRequest:
any_tag: bool = False
order: Optional[str] = None
url: Optional[str] = None # Filter by URL (partial match)
suffix: Optional[str] = None # Filter by primary file suffix (e.g. ".bin"), case-insensitive exact match
path: Optional[str] = None # Filter by primary_path (case-insensitive partial match)
def __post_init__(self):
if self.any_tag and self.tag_names:

View file

@ -117,6 +117,36 @@ async def test_list_files_api(test_session, async_client, make_files_structure,
await check_get_files(['videos', 'archives'], expected)
@pytest.mark.asyncio
async def test_list_files_api_hides_ds_store(test_session, async_client, make_files_structure, test_directory):
"""macOS metadata files (.DS_Store / ._.DS_Store) are never shown in the FileBrowser."""
make_files_structure([
'.DS_Store', # Top-level metadata file should be hidden.
'._.DS_Store', # Top-level AppleDouble metadata file should be hidden.
'archives/foo.txt',
'archives/.DS_Store', # Nested metadata file should be hidden.
'archives/._.DS_Store', # Nested AppleDouble metadata file should be hidden.
])
async def get_files(directories):
request, response = await async_client.post('/api/files', content=json.dumps({'directories': directories}))
assert response.status_code == HTTPStatus.OK
return response.json['files']
# Top-level listing hides the metadata files.
top_level = await get_files([])
assert '.DS_Store' not in top_level
assert '._.DS_Store' not in top_level
assert 'archives/' in top_level
# Nested listing hides the metadata files, but still shows real files.
nested = await get_files(['archives'])
children = nested['archives/']['children']
assert '.DS_Store' not in children
assert '._.DS_Store' not in children
assert 'foo.txt' in children
@pytest.mark.asyncio
async def test_delete_file(test_session, async_client, make_files_structure, test_directory):
files = ['bar.txt', 'baz/', 'foo']
@ -285,6 +315,50 @@ async def test_files_search(test_session, async_client, make_files_structure, as
await assert_files_search('nothing', [])
@pytest.mark.asyncio
async def test_files_search_by_suffix(test_session, async_client, make_files_structure):
"""Files can be filtered by their primary file's suffix (used by the firmware flasher to find .bin files)."""
files = [
'software/MALVEKE.ino.bin',
'software/bffb/esp32_marauder.ino.bootloader.bin',
'software/firmware-heltec.uf2',
'software/firmware-r1-neo.uf2',
'notes.txt',
]
created = make_files_structure(files)
for path in created:
path.write_bytes(b'\x00\x01\x02\x03')
request, response = await async_client.post('/api/files/refresh')
assert response.status_code == HTTPStatus.NO_CONTENT
async def search(**kwargs):
request, response = await async_client.post('/api/files/search', json=kwargs)
assert response.status_code == HTTPStatus.OK
return sorted(fg['primary_path'] for fg in response.json['file_groups'])
# Exact suffix match, normalized whether or not a leading dot is given, and case-insensitive.
assert await search(suffix='.bin') == ['software/MALVEKE.ino.bin',
'software/bffb/esp32_marauder.ino.bootloader.bin']
assert await search(suffix='bin') == ['software/MALVEKE.ino.bin',
'software/bffb/esp32_marauder.ino.bootloader.bin']
assert await search(suffix='.BIN') == ['software/MALVEKE.ino.bin',
'software/bffb/esp32_marauder.ino.bootloader.bin']
assert await search(suffix='.uf2') == ['software/firmware-heltec.uf2', 'software/firmware-r1-neo.uf2']
assert await search(suffix='.txt') == ['notes.txt']
# A suffix that matches nothing returns no results.
assert await search(suffix='.gguf') == []
# The `path` filter matches anywhere in the primary_path (case-insensitive), so a directory name works.
assert await search(path='bffb') == ['software/bffb/esp32_marauder.ino.bootloader.bin']
assert await search(path='BFFB') == ['software/bffb/esp32_marauder.ino.bootloader.bin']
assert await search(path='software/') == ['software/MALVEKE.ino.bin',
'software/bffb/esp32_marauder.ino.bootloader.bin',
'software/firmware-heltec.uf2', 'software/firmware-r1-neo.uf2']
# `suffix` and `path` combine (the firmware picker sends both).
assert await search(suffix='.bin', path='bffb') == ['software/bffb/esp32_marauder.ino.bootloader.bin']
@pytest.mark.asyncio
async def test_files_search_attaches_doc_section_hint(async_client, test_session, test_directory,
example_epub, refresh_files):

View file

@ -260,6 +260,33 @@ async def test_refresh_files(async_client, test_session, make_files_structure, a
assert_file_groups([{'primary_path': 'bar.txt', 'indexed': True}])
@pytest.mark.asyncio
async def test_refresh_populates_suffix(async_client, test_session, make_files_structure, refresh_files):
"""A refresh populates `file_group.suffix` for every file it discovers.
Regression: suffix is path-derived metadata (like mimetype) and must be set when the FileGroup is
upserted during discovery, not deferred to content-indexing. Deferring it meant a file that got marked
indexed through a path that skipped that step ended up indexed with a NULL suffix and was invisible to
suffix search (the firmware flasher's media picker)."""
make_files_structure([
'software/firmware.bin',
'software/bffb/esp32_marauder.ino.bootloader.bin',
'notes.txt',
])
await refresh_files()
file_groups = test_session.query(FileGroup).all()
# Suffix is set for every file the moment it is discovered, and every row is indexed.
assert all(fg.indexed for fg in file_groups)
suffixes = {fg.primary_path.name: fg.suffix for fg in file_groups}
assert suffixes == {
'firmware.bin': '.bin',
'esp32_marauder.ino.bootloader.bin': '.bin',
'notes.txt': '.txt',
}
@pytest.mark.asyncio
async def test_file_group_location(async_client, test_session, make_files_structure, refresh_files):
"""FileGroup can return the URL necessary to preview the FileGroup."""

View file

@ -15,6 +15,7 @@ from vininfo.details._base import VinDetails
from modules.archive.api import archive_bp
from modules.docs.api import docs_bp
from modules.flasher.api import flasher_bp
from modules.inventory import inventory_bp
from modules.map.api import map_bp
from modules.videos.api import videos_bp
@ -52,6 +53,7 @@ api_app.blueprint(collection_bp) # Unified collection endpoints
api_app.blueprint(config_bp)
api_app.blueprint(docs_bp)
api_app.blueprint(files_bp)
api_app.blueprint(flasher_bp)
api_app.blueprint(inventory_bp)
api_app.blueprint(map_bp)
api_app.blueprint(videos_bp)
@ -156,6 +158,8 @@ def get_settings(_: Request):
'archive_destination': wrolpi_config.archive_destination,
'download_manager_disabled': download_manager.is_disabled,
'download_manager_stopped': download_manager.is_stopped,
'download_daily_limit_global': wrolpi_config.download_daily_limit_global,
'download_daily_limit_per_domain': wrolpi_config.download_daily_limit_per_domain,
'download_on_startup': wrolpi_config.download_on_startup,
'download_timeout': wrolpi_config.download_timeout,
'download_wait': wrolpi_config.download_wait,
@ -266,6 +270,12 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
except (KeyError, Exception):
raise InvalidConfig(f'Invalid timezone: {body.timezone}')
# Daily download limits must be non-negative integers. 0 (or null) means unlimited.
for field_name in ('download_daily_limit_global', 'download_daily_limit_per_domain'):
value = getattr(body, field_name, None)
if value is not None and value < 0:
raise InvalidConfig(f'{field_name} must be a non-negative integer')
# Handle download window fields: empty string clears the value.
for field_name in ('download_window_start', 'download_window_end'):
value = getattr(body, field_name, None)

View file

@ -16,6 +16,8 @@ class SettingsObject:
class SettingsResponse:
download_manager_disabled: bool
download_manager_stopped: bool
download_daily_limit_global: Optional[int]
download_daily_limit_per_domain: Optional[int]
download_on_startup: bool
download_timeout: int
download_wait: int
@ -63,6 +65,8 @@ class SemanticUIColors(enum.StrEnum):
@dataclass
class SettingsRequest:
archive_destination: Optional[str] = None
download_daily_limit_global: Optional[int] = None
download_daily_limit_per_domain: Optional[int] = None
download_on_startup: Optional[bool] = None
download_timeout: Optional[int] = None
download_wait: Optional[int] = None
@ -297,8 +301,9 @@ class DownloadSettings:
if not self.download_metadata_only:
del self.download_metadata_only
if self.download_order not in (None, 'newest', 'oldest', 'views'):
raise ValidationError(f'Download order must be one of newest, oldest, views, or null.')
# 'views' was deprecated: YouTube no longer provides view counts in channel/playlist listings.
if self.download_order not in (None, 'newest', 'oldest'):
raise ValidationError(f'Download order must be one of newest, oldest, or null.')
if self.video_format not in (None, 'mp4', 'mkv', 'ogg', 'webm'):
raise ValidationError(f'Video format must be one of mp4, mkv, ogg, webm, or null.')

View file

@ -2,7 +2,7 @@ import asyncio
import json
import pathlib
from abc import ABC
from datetime import datetime, time, timezone
from datetime import datetime, time, timezone, timedelta
from http import HTTPStatus
from itertools import zip_longest
from unittest import mock
@ -11,8 +11,9 @@ import pytest
import pytz
import yaml
from wrolpi.common import get_wrolpi_config
from wrolpi.dates import Seconds
from wrolpi.api_utils import api_app
from wrolpi.common import get_wrolpi_config, normalize_domain
from wrolpi.dates import Seconds, now
from wrolpi.db import get_db_context
from wrolpi.downloader import Downloader, Download, DownloadFrequency, import_downloads_config, \
get_download_manager_config, RSSDownloader, parse_aria2c_progress, _parse_size, \
@ -913,6 +914,20 @@ def test_download_settings_coerces_numeric_strings():
assert 'max_pages' not in req.settings
@pytest.mark.parametrize('order', ['newest', 'oldest', None])
def test_download_settings_accepts_valid_download_order(order):
from wrolpi.schema import DownloadSettings
DownloadSettings(download_order=order)
def test_download_settings_rejects_deprecated_views_order():
"""'views' download order was deprecated; YouTube no longer provides view counts in listings."""
from wrolpi.schema import DownloadSettings
from wrolpi.errors import ValidationError
with pytest.raises(ValidationError):
DownloadSettings(download_order='views')
@pytest.mark.asyncio
async def test_downloads_config(test_session, test_download_manager, test_download_manager_config,
test_downloader, assert_downloads, tag_factory, await_switches):
@ -1586,3 +1601,225 @@ def test_require_media_mounted_gate_opt_out(test_download_manager, test_wrolpi_c
with mock.patch('wrolpi.downloader.PYTEST', False), \
mock.patch('wrolpi.downloader.wrol_mode_enabled', return_value=False):
assert test_download_manager.can_download is True
# ===================================================================================================
# Daily download limits (per-domain and global)
# ===================================================================================================
def test_normalize_domain_helper():
"""The generic domain normalizer strips port, www, and other common subdomains."""
assert normalize_domain('https://www.example.com/foo') == 'example.com'
assert normalize_domain('https://m.example.com/foo') == 'example.com'
assert normalize_domain('https://mobile.example.com/foo') == 'example.com'
assert normalize_domain('https://example.com:443/foo') == 'example.com'
assert normalize_domain('https://example.com/foo') == 'example.com'
def test_base_downloader_normalize_domain(test_downloader):
"""The base Downloader.normalize_domain strips common subdomains."""
assert test_downloader.normalize_domain('https://m.rumble.com/abc') == 'rumble.com'
assert test_downloader.normalize_domain('https://www.rumble.com/abc') == 'rumble.com'
def test_video_downloader_normalize_domain():
"""VideoDownloader/ChannelDownloader collapse youtu.be, shorts, and tracking params to youtube.com."""
from modules.videos.downloader import VideoDownloader, ChannelDownloader
# Build instances without __init__ to avoid re-registering with the global manager.
vd = object.__new__(VideoDownloader)
cd = object.__new__(ChannelDownloader)
for downloader in (vd, cd):
assert downloader.normalize_domain('https://youtu.be/abc12345') == 'youtube.com'
assert downloader.normalize_domain('https://www.youtube.com/shorts/abc12345') == 'youtube.com'
assert downloader.normalize_domain('https://m.youtube.com/watch?v=abc12345') == 'youtube.com'
assert downloader.normalize_domain(
'https://www.youtube.com/watch?v=abc12345&list=PL123&si=foo') == 'youtube.com'
def _make_processed_download(session, downloader_name, url, *, when=None, frequency=None, status='complete'):
"""Create a Download that looks like it was already attempted (for daily-limit counting)."""
download = Download(url=url, downloader=downloader_name, frequency=frequency, status=status)
download.last_download_attempt = when if when is not None else now()
session.add(download)
session.flush()
return download
def _dispatched_urls(dispatch_mock) -> set:
return {c.kwargs['context']['download_url'] for c in dispatch_mock.call_args_list}
@pytest.mark.asyncio
async def test_daily_download_counts(test_session, test_download_manager, test_downloader):
"""daily_download_counts tallies non-recurring downloads attempted since local midnight, by domain."""
name = test_downloader.name
# Counted today: two example.com (one via m. subdomain), one rumble.com.
_make_processed_download(test_session, name, 'https://example.com/1')
_make_processed_download(test_session, name, 'https://m.example.com/2')
_make_processed_download(test_session, name, 'https://rumble.com/3')
# Not counted: recurring (has frequency), and one attempted two days ago.
_make_processed_download(test_session, name, 'https://example.com/recurring',
frequency=DownloadFrequency.daily)
_make_processed_download(test_session, name, 'https://example.com/yesterday',
when=now() - timedelta(days=2))
test_session.commit()
global_count, domain_counts = test_download_manager.daily_download_counts(test_session)
assert global_count == 3
assert domain_counts == {'example.com': 2, 'rumble.com': 1}
@pytest.mark.asyncio
async def test_daily_limit_per_domain_blocks(test_session, test_download_manager, test_downloader):
"""A new download is not dispatched once its domain hits the per-domain daily limit."""
config = get_wrolpi_config()
config.download_daily_limit_per_domain = 3
name = test_downloader.name
for i in range(3):
_make_processed_download(test_session, name, f'https://example.com/done{i}')
# A different domain under its own limit must still be allowed.
new_blocked = Download(url='https://example.com/new', downloader=name, status='new')
new_allowed = Download(url='https://rumble.com/new', downloader=name, status='new')
test_session.add_all([new_blocked, new_allowed])
test_session.commit()
with mock.patch.object(type(api_app), 'dispatch', new_callable=mock.AsyncMock) as dispatch:
await test_download_manager.dispatch_downloads()
dispatched = _dispatched_urls(dispatch)
assert 'https://example.com/new' not in dispatched
assert 'https://rumble.com/new' in dispatched
test_session.refresh(new_blocked)
assert new_blocked.last_download_attempt is None
assert new_blocked.status == 'new'
@pytest.mark.asyncio
async def test_daily_limit_per_domain_allows_under_limit(test_session, test_download_manager, test_downloader):
"""A new download is dispatched while its domain is under the per-domain daily limit."""
config = get_wrolpi_config()
config.download_daily_limit_per_domain = 3
name = test_downloader.name
for i in range(2):
_make_processed_download(test_session, name, f'https://example.com/done{i}')
new = Download(url='https://example.com/new', downloader=name, status='new')
test_session.add(new)
test_session.commit()
with mock.patch.object(type(api_app), 'dispatch', new_callable=mock.AsyncMock) as dispatch:
await test_download_manager.dispatch_downloads()
assert 'https://example.com/new' in _dispatched_urls(dispatch)
test_session.refresh(new)
assert new.last_download_attempt is not None
@pytest.mark.asyncio
async def test_daily_limit_resets_next_day(test_session, test_download_manager, test_downloader):
"""Downloads attempted on a previous day do not count against today's limit."""
config = get_wrolpi_config()
config.download_daily_limit_per_domain = 3
name = test_downloader.name
# Three were processed two days ago; they should not count today.
for i in range(3):
_make_processed_download(test_session, name, f'https://example.com/old{i}',
when=now() - timedelta(days=2))
new = Download(url='https://example.com/new', downloader=name, status='new')
test_session.add(new)
test_session.commit()
with mock.patch.object(type(api_app), 'dispatch', new_callable=mock.AsyncMock) as dispatch:
await test_download_manager.dispatch_downloads()
assert 'https://example.com/new' in _dispatched_urls(dispatch)
@pytest.mark.asyncio
async def test_daily_limit_counts_failed_attempts(test_session, test_download_manager, test_downloader):
"""A failed/deferred download attempted today still consumes a daily slot."""
config = get_wrolpi_config()
config.download_daily_limit_per_domain = 2
name = test_downloader.name
# One complete, one deferred (failed) today => 2 attempts, domain is at its limit.
_make_processed_download(test_session, name, 'https://example.com/ok', status='complete')
_make_processed_download(test_session, name, 'https://example.com/failed', status='deferred')
new = Download(url='https://example.com/new', downloader=name, status='new')
test_session.add(new)
test_session.commit()
with mock.patch.object(type(api_app), 'dispatch', new_callable=mock.AsyncMock) as dispatch:
await test_download_manager.dispatch_downloads()
assert 'https://example.com/new' not in _dispatched_urls(dispatch)
@pytest.mark.asyncio
async def test_daily_limit_global_blocks(test_session, test_download_manager, test_downloader):
"""The global daily limit caps downloads across all domains."""
config = get_wrolpi_config()
config.download_daily_limit_global = 3
name = test_downloader.name
# 3 processed today across domains (global limit reached).
_make_processed_download(test_session, name, 'https://example.com/1')
_make_processed_download(test_session, name, 'https://example.com/2')
_make_processed_download(test_session, name, 'https://rumble.com/3')
# A brand-new domain with no prior downloads is still blocked by the global cap.
new = Download(url='https://wikipedia.org/new', downloader=name, status='new')
test_session.add(new)
test_session.commit()
with mock.patch.object(type(api_app), 'dispatch', new_callable=mock.AsyncMock) as dispatch:
await test_download_manager.dispatch_downloads()
assert _dispatched_urls(dispatch) == set()
test_session.refresh(new)
assert new.last_download_attempt is None
@pytest.mark.asyncio
async def test_daily_limit_global_allows_under_limit(test_session, test_download_manager, test_downloader):
"""Downloads dispatch while the global daily total is under the limit."""
config = get_wrolpi_config()
config.download_daily_limit_global = 5
name = test_downloader.name
for i in range(3):
_make_processed_download(test_session, name, f'https://example.com/{i}')
new = Download(url='https://wikipedia.org/new', downloader=name, status='new')
test_session.add(new)
test_session.commit()
with mock.patch.object(type(api_app), 'dispatch', new_callable=mock.AsyncMock) as dispatch:
await test_download_manager.dispatch_downloads()
assert 'https://wikipedia.org/new' in _dispatched_urls(dispatch)
@pytest.mark.asyncio
async def test_recurring_downloads_bypass_daily_limits(test_session, test_download_manager, test_downloader):
"""Recurring (Channel/RSS) downloads are never gated and never consume a daily slot."""
config = get_wrolpi_config()
# Both limits would otherwise block everything past the first download.
config.download_daily_limit_global = 2
config.download_daily_limit_per_domain = 2
name = test_downloader.name
# One non-recurring already processed today (global count = 1).
_make_processed_download(test_session, name, 'https://example.com/done')
# A recurring download on a different domain, and a new non-recurring download.
recurring = Download(url='https://rumble.com/channel', downloader=name, status='new',
frequency=DownloadFrequency.daily)
non_recurring = Download(url='https://example.com/new', downloader=name, status='new')
test_session.add_all([recurring, non_recurring])
test_session.commit()
with mock.patch.object(type(api_app), 'dispatch', new_callable=mock.AsyncMock) as dispatch:
await test_download_manager.dispatch_downloads()
dispatched = _dispatched_urls(dispatch)
# Recurring is dispatched despite the limits. The non-recurring is also dispatched, which proves the
# recurring download did not consume a global slot (else the global count would have reached 2 first).
assert 'https://rumble.com/channel' in dispatched
assert 'https://example.com/new' in dispatched
# Recurring downloads are excluded from the daily count entirely.
global_count, domain_counts = test_download_manager.daily_download_counts(test_session)
assert 'rumble.com' not in domain_counts

View file

@ -55,6 +55,35 @@ async def test_get_settings(test_session, async_client):
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_daily_download_limit_settings(test_session, async_client):
"""The daily download limits round-trip through the settings API and reject negative values."""
# Defaults are unlimited (None).
request, response = await async_client.get('/api/settings')
assert response.json['download_daily_limit_global'] is None
assert response.json['download_daily_limit_per_domain'] is None
data = {'download_daily_limit_global': 100, 'download_daily_limit_per_domain': 50}
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = await async_client.get('/api/settings')
assert response.json['download_daily_limit_global'] == 100
assert response.json['download_daily_limit_per_domain'] == 50
assert get_wrolpi_config().download_daily_limit_global == 100
assert get_wrolpi_config().download_daily_limit_per_domain == 50
# 0 means unlimited and is accepted.
data = {'download_daily_limit_per_domain': 0}
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.NO_CONTENT
# Negative values are rejected.
data = {'download_daily_limit_global': -1}
request, response = await async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_log_level(async_client):
"""Log level must be between 0 and 40."""

View file

@ -1 +1 @@
0.25.3-beta
0.26.0-beta