mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Merge branch 'master' into release
# Conflicts: # wrolpi/version.txt
This commit is contained in:
commit
d364cf10ec
47 changed files with 3411 additions and 349 deletions
|
|
@ -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')
|
||||
69
alembic/versions/d4e6f8a0c2b5_file_group_suffix.py
Normal file
69
alembic/versions/d4e6f8a0c2b5_file_group_suffix.py
Normal 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
140
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? <> 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}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
|
|
|
|||
1078
app/src/components/Flasher.js
Normal file
1078
app/src/components/Flasher.js
Normal file
File diff suppressed because it is too large
Load diff
420
app/src/components/Flasher.test.js
Normal file
420
app/src/components/Flasher.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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'},
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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={<>
|
||||
|
|
|
|||
221
app/src/components/calculators/OneTimePadCalculator.js
Normal file
221
app/src/components/calculators/OneTimePadCalculator.js
Normal 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}/>
|
||||
</>
|
||||
}
|
||||
28
app/src/components/calculators/OneTimePadCalculator.test.js
Normal file
28
app/src/components/calculators/OneTimePadCalculator.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
53
app/src/components/calculators/VinDecoderCalculator.js
Normal file
53
app/src/components/calculators/VinDecoderCalculator.js
Normal 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}
|
||||
</>
|
||||
}
|
||||
14
app/src/components/calculators/VinDecoderCalculator.test.js
Normal file
14
app/src/components/calculators/VinDecoderCalculator.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
0
modules/flasher/__init__.py
Normal file
0
modules/flasher/__init__.py
Normal file
55
modules/flasher/api.py
Normal file
55
modules/flasher/api.py
Normal 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
86
modules/flasher/config.py
Normal 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
|
||||
71
modules/flasher/conftest.py
Normal file
71
modules/flasher/conftest.py
Normal 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
135
modules/flasher/lib.py
Normal 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
20
modules/flasher/schema.py
Normal 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
|
||||
0
modules/flasher/test/__init__.py
Normal file
0
modules/flasher/test/__init__.py
Normal file
160
modules/flasher/test/test_flasher.py
Normal file
160
modules/flasher/test/test_flasher.py
Normal 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
|
||||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
0.25.3-beta
|
||||
0.26.0-beta
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue