diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index a3b512e..7ff9b52 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -1,16 +1,16 @@ -name: Test Bundles - -on: - pull_request: - -jobs: - bundle: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v2 - - name: build ci environment - run: | - docker build --pull --rm -t qrapps:ci . - - name: generate QR - run: | - docker run --rm -v .:/src -w /src qrapps:ci qrs.py --htmldirs=apps --builddir=bundles +name: Test Bundles + +on: + pull_request: + +jobs: + bundle: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: build ci environment + run: | + docker compose build + - name: generate QR + run: | + docker compose run --rm qrs diff --git a/.github/workflows/prospector.yml b/.github/workflows/prospector.yml deleted file mode 100644 index 6937ec3..0000000 --- a/.github/workflows/prospector.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Prospector - -on: - pull_request: - -jobs: - prospector: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v2 - - name: build ci environment - run: | - docker build --pull --rm -t qrapps:ci . - - name: lint with prospector - run: | - docker run --rm -v .:/src -w /src --entrypoint=prospector qrapps:ci \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27c5cab..b470594 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,32 +1,31 @@ -name: Release Bundles - -on: - push: - branches: [ main ] - -jobs: - bundle: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v2 - - name: build ci environment - run: | - docker build --pull --rm -t qrapps:ci . - - name: generate QR - run: | - docker run --rm -v .:/src -w /src qrapps:ci qrs.py --htmldirs=apps --builddir=bundles - - name: generate README.md - run: | - rm -rf docs - mv bundles docs - sudo cp -at docs .google/* - sudo git checkout -- docs/_config.yml - docker run --rm -v .:/src -w /src qrapps:ci release.py - - name: release - run: | - git config --global user.email "woldemar@mimas.ru" - git config --global user.name "Vladimir Looze" - git add docs - git commit -m "release new bundles" - git push --force origin main - +name: Release Bundles + +on: + push: + branches: [ main ] + +jobs: + bundle: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: build ci environment + run: | + docker compose build + - name: generate QR + run: | + docker compose run --rm qrs + - name: generate README.md + run: | + rm -rf docs + mv bundles docs + sudo cp -at docs .google/* + sudo git checkout -- docs/_config.yml + docker compose run --rm release + - name: release + run: | + git config --global user.email "woldemar@mimas.ru" + git config --global user.name "Vladimir Looze" + git add docs + git commit -m "release new bundles" + git push --force origin main diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..a44bd89 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,16 @@ +name: ruff + +on: + pull_request: + +jobs: + ruff: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: build ci environment + run: | + docker compose build + - name: lint with ruff + run: | + docker compose run --rm lint diff --git a/Dockerfile b/Dockerfile index 352fed5..33650f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,40 +1,23 @@ -# Self-contained environment, useful commands: -# -# Build environment image: -# docker build --pull --rm -t qrapps:ci . -# -# After image has been built, you can run: -# -# Interactive shell, useful for debugging: -# docker run --rm -it -v .:/src -w /src --entrypoint=bash qrapps:ci -# -# Code quality check: -# docker run --rm -v .:/src -w /src --entrypoint=prospector qrapps:ci -# -# Convert everything to QR codes: -# docker run --rm -v .:/src -w /src qrapps:ci qrs.py --htmldirs=apps --builddir=bundles -# -# Convert single app to QR code: -# docker run --rm -v .:/src -w /src qrapps:ci qr.py --htmldir=apps/demo-clock --builddir=bundles +# Self-contained environment for QR Apps project -from debian:12 +FROM debian:12 RUN apt-get -y update && apt-get install -y --no-install-recommends \ python3-pip=23.0.1+dfsg-1 \ python3-venv=3.11.2-1+b1 \ npm=9.2.0~ds1-1 \ - && \ - rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* -RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \ - --mount=type=bind,source=requirements-contrib.txt,target=requirements-contrib.txt \ - python3 -m venv /venv && \ +COPY requirements.txt /tmp/requirements.txt +COPY requirements-contrib.txt /tmp/requirements-contrib.txt + +RUN python3 -m venv /venv && \ /venv/bin/python3 -m pip install --upgrade pip && \ - /venv/bin/python3 -m pip install -r requirements.txt && \ - /venv/bin/python3 -m pip install -r requirements-contrib.txt + /venv/bin/python3 -m pip install -r /tmp/requirements.txt && \ + /venv/bin/python3 -m pip install -r /tmp/requirements-contrib.txt RUN npm install -g uglify-js html-minifier inline-scripts -ENV PATH="/venv/bin:${PATH}" - +ENV PATH=/venv/bin:$PATH +WORKDIR /src ENTRYPOINT ["/venv/bin/python3"] \ No newline at end of file diff --git a/README.md b/README.md index 90aff0c..1c25edc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -[![CodeQL](https://github.com/worldemar/qrapps/actions/workflows/codeql.yml/badge.svg)](https://github.com/worldemar/qrapps/actions/workflows/codeql.yml) +[![CodeQL](https://github.com/worldemar/qrapps/actions/workflows/codeql.yml/badge.svg)](https://github.com/worldemar/qrapps/actions/workflows/codeql.yml) [![GutHub Pages](https://github.com/worldemar/qrapps/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/worldemar/qrapps/actions/workflows/pages/pages-build-deployment) -[![prospector](https://github.com/worldemar/qrapps/actions/workflows/prospector.yml/badge.svg)](https://github.com/worldemar/qrapps/actions/workflows/prospector.yml) +[![ruff](https://github.com/worldemar/qrapps/actions/workflows/ruff.yml/badge.svg)](https://github.com/worldemar/qrapps/actions/workflows/ruff.yml) # QR Code Apps @@ -24,6 +24,7 @@ Since QR code content is [Data URL](https://en.wikipedia.org/wiki/Data_URI_schem (see `demo-*` apps for file content examples) - Use build instructions from [Dockerfile](Dockerfile) +- See [docker.md](docker.md) for Docker Compose commands ## Why would you do that? For fun, challenge and as a demonstration of modern browser capabilities. diff --git a/apps/demo-segmented-clock/README.md b/apps/demo-segmented-clock/README.md new file mode 100644 index 0000000..2a2b55e --- /dev/null +++ b/apps/demo-segmented-clock/README.md @@ -0,0 +1,3 @@ +# Segmented Clock + +A digital clock display using 7-segment display style. Shows hours, minutes, and seconds with blinking colons between them. \ No newline at end of file diff --git a/apps/demo-segmented-clock/index.html b/apps/demo-segmented-clock/index.html new file mode 100644 index 0000000..12bdb35 --- /dev/null +++ b/apps/demo-segmented-clock/index.html @@ -0,0 +1,75 @@ + + + + + + \ No newline at end of file diff --git a/apps/demo-segmented-clock/index.js b/apps/demo-segmented-clock/index.js new file mode 100644 index 0000000..617bd16 --- /dev/null +++ b/apps/demo-segmented-clock/index.js @@ -0,0 +1,72 @@ +const segments = { + 0: ['a','b','c','d','e','f'], + 1: ['b','c'], + 2: ['a','b','g','e','d'], + 3: ['a','b','g','c','d'], + 4: ['f','g','b','c'], + 5: ['a','f','g','c','d'], + 6: ['a','f','g','c','d','e'], + 7: ['a','b','c'], + 8: ['a','b','c','d','e','f','g'], + 9: ['a','b','c','d','f','g'] +}; + +function createDigit() { + const digit = document.createElement('div'); + digit.className = 'digit'; + ['a','b','c','d','e','f','g'].forEach(seg => { + const segment = document.createElement('div'); + segment.className = `segment ${seg}`; + digit.appendChild(segment); + }); + return digit; +} + +function createColon() { + const colon = document.createElement('div'); + colon.className = 'colon'; + const dot1 = document.createElement('div'); + dot1.className = 'dot top'; + const dot2 = document.createElement('div'); + dot2.className = 'dot bottom'; + colon.appendChild(dot1); + colon.appendChild(dot2); + return colon; +} + +function updateDigit(digit, number) { + const activeSegments = segments[number] || []; + digit.querySelectorAll('.segment').forEach(seg => { + seg.style.opacity = activeSegments.includes(seg.className.split(' ')[1]) ? '1' : '0.1'; + }); +} + +function updateClock() { + const now = new Date(); + const hours = now.getHours().toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + + const digits = document.querySelectorAll('.digit'); + updateDigit(digits[1], hours[0]); + updateDigit(digits[2], hours[1]); + updateDigit(digits[4], minutes[0]); + updateDigit(digits[5], minutes[1]); + updateDigit(digits[7], seconds[0]); + updateDigit(digits[8], seconds[1]); +} + +function init() { + const clock = document.getElementById('clock'); + // Create 6 digits (2 for hours, 2 for minutes, 2 for seconds) + for (let i = 0; i < 6; i++) { + clock.appendChild(createDigit()); + if (i === 1 || i === 3) { + clock.appendChild(createColon()); + } + } + updateClock(); + setInterval(updateClock, 1000); +} + +init(); \ No newline at end of file diff --git a/apps/demo-segmented-clock/minify.json b/apps/demo-segmented-clock/minify.json new file mode 100644 index 0000000..a7b6afa --- /dev/null +++ b/apps/demo-segmented-clock/minify.json @@ -0,0 +1,6 @@ +{ + "collapseWhitespace": true, + "minifyJS": true, + "minifyCSS": true, + "removeOptionalTags": true +} \ No newline at end of file diff --git a/apps/demo-segmented-clock/uglify.json b/apps/demo-segmented-clock/uglify.json new file mode 100644 index 0000000..48099a8 --- /dev/null +++ b/apps/demo-segmented-clock/uglify.json @@ -0,0 +1,15 @@ +{ + "mangle": true, + "toplevel": true, + "compress": { + "sequences": true, + "dead_code": true, + "conditionals": true, + "loops": true, + "booleans": true, + "unused": true, + "if_return": true, + "join_vars": true, + "drop_console": true + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e8c597e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,95 @@ +# Docker Compose for QR Apps project + +services: + # Interactive shell for debugging + shell: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + entrypoint: bash + stdin_open: true + tty: true + + # Code quality check with ruff + lint: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + entrypoint: ["ruff", "check"] + + # Convert all apps to QR codes + qrs: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + command: ["qrs.py", "--htmldirs=apps", "--builddir=bundles"] + + # Convert single app to QR code + # Usage: docker-compose run --rm qr --htmldir=apps/demo-clock --builddir=bundles + qr: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + entrypoint: ["python3", "qr.py"] + + # Generate release documentation + release: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + command: ["release.py"] + + # Run uglify-js + uglifyjs: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + entrypoint: uglifyjs + + # Run html-minifier + html-minifier: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + entrypoint: html-minifier + + # Run inline-script-tags + inline-script-tags: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + entrypoint: inline-script-tags + + # Run inline-stylesheets + inline-stylesheets: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/src + working_dir: /src + entrypoint: inline-stylesheets diff --git a/docker.md b/docker.md new file mode 100644 index 0000000..bf1814a --- /dev/null +++ b/docker.md @@ -0,0 +1,72 @@ +# Docker Compose Commands + +This project uses Docker Compose to simplify all development and build commands. + +## Prerequisites + +- Docker Desktop installed +- Docker Compose (v2+) installed + +## Build the Environment + +```bash +docker-compose build +``` + +## Interactive Shell for Debugging + +```bash +docker-compose run --rm shell +``` + +Opens an interactive bash shell with all dependencies installed and your local code mounted. + +## Code Quality Check + +```bash +docker-compose run --rm lint +``` + +Runs ruff linting on Python code. + +## Convert Everything to QR Codes + +```bash +docker-compose run --rm qrs +``` + +Converts all apps in `apps/` directory to QR codes in `bundles/`. + +## Convert Single App to QR Code + +```bash +docker-compose run --rm qr --htmldir=apps/demo-clock --builddir=bundles +``` + +Replace `demo-clock` with any app directory name under `apps/`. + +## Release Bundles + +```bash +docker-compose run --rm release +``` + +Generates `docs/README.md` with links and QR codes for all apps. + +## Additional Utility Commands + +### Minification Tools + +```bash +# Uglify JS files +docker-compose run --rm uglifyjs [options] + +# Minify HTML +docker-compose run --rm html-minifier [options] + +# Inline script tags +docker-compose run --rm inline-script-tags + +# Inline stylesheets +docker-compose run --rm inline-stylesheets +``` diff --git a/qr.py b/qr.py index f6d7254..bb32436 100644 --- a/qr.py +++ b/qr.py @@ -1,159 +1,159 @@ -import os -import shutil -import argparse -import subprocess -import qrcode -from qrcode.image.styledpil import StyledPilImage -import qrcode.constants - - -def html_to_url(html_filename, txt_filename): - with open(html_filename, 'rb') as f_html: - html_text = f_html.read().decode('utf-8') - - print(f'HTML src size: {len(html_text)}') - - html_text_escaped = html_text - - # % operator (division reminder) is rarely used in JS - # but can still appear and mess up percent-encoding of Data URL. - html_text_escaped = html_text_escaped.replace('%', '%' + '25') - - # You might think urllib.parse.quote is a better solution, - # but it escapes too much stuff and bloats data, - # significantly reducing effective QR capacity - html_text_escaped = html_text_escaped.replace('#', '%' + '23') - - print(f'HTML rdy size: {len(html_text_escaped)}') - data_url = 'data:text/html,' + html_text_escaped - qr_data_encoded = data_url.encode('ascii') - with open(txt_filename, 'wb') as f_txt: - f_txt.write(qr_data_encoded) - - -def url_to_qr(url_filename, png_filename): - qr_code = qrcode.QRCode( - version=None, - image_factory=StyledPilImage, - border=4, - box_size=2, - error_correction=qrcode.constants.ERROR_CORRECT_L, - ) - with open(url_filename, 'rb') as f_url: - html_text = f_url.read().decode('utf-8') - qr_code.add_data(html_text) - print('QR data dump:') - for i in qr_code.data_list: - print(f'- bytes={len(i.data)} mode={i.mode} content={i.data[:64]}') - print(f'QR code fit size: {qr_code.best_fit()}') - qr_code.make(fit=True) - img = qr_code.make_image() - img.save(png_filename) - - -def inline_scripts(html_filename, out_filename): - subprocess.check_call([ - 'inline-script-tags', - html_filename, - out_filename]) - - -def inline_stylesheets(html_filename, out_filename): - subprocess.check_call([ - 'inline-stylesheets', - html_filename, - out_filename]) - - -def minify(html_filename, out_filename, config_file): - subprocess.check_call([ - 'html-minifier', - '--config-file', config_file, - html_filename, - '--output', out_filename]) - - -def uglify(html_dir, html_tmp_dir): - def js_filter(file_name): - return file_name.endswith('.js') - list_dir = os.listdir(html_dir) - js_files = list( - os.path.join(html_dir, x) for x in filter(js_filter, list_dir)) - js_tmp_files = list( - os.path.join(html_tmp_dir, x) for x in filter(js_filter, list_dir)) - js_pairs = zip(js_files, js_tmp_files) - for js_pair in js_pairs: - subprocess.check_call([ - 'uglifyjs', - '--output', js_pair[1], - '--warn', - '--config-file', os.path.join(html_dir, 'uglify.json'), - '--', js_pair[0]]) - - -def parse_args(): - parser = argparse.ArgumentParser('Convert HTML page into QR code') - parser.add_argument('--htmldir', - help='directory with html page') - parser.add_argument('--builddir', - help='directory to put bundle and QR code into') - return parser.parse_args() - - -def main(): - args = parse_args() - - html_tmp_dir = os.path.basename(args.htmldir) + '.tmp' - app_name = os.path.basename(args.htmldir) - deploy_directory = os.path.join(args.builddir, app_name) - if not os.path.isdir(deploy_directory): - os.makedirs(deploy_directory) - - bundle_filename = os.path.join(html_tmp_dir, 'index.html') - mini_filename = os.path.join(deploy_directory, 'index.html') - url_filename = os.path.join(deploy_directory, 'qr.txt') - png_filename = os.path.join(deploy_directory, 'qr.png') - - if os.path.isdir(html_tmp_dir): - shutil.rmtree(html_tmp_dir) - shutil.copytree(args.htmldir, html_tmp_dir) - uglify(args.htmldir, html_tmp_dir) - - tmp_file = os.path.join(html_tmp_dir, 'index.html.tmp') - inline_scripts( - os.path.join(html_tmp_dir, 'index.html'), - tmp_file - ) - inline_stylesheets( - tmp_file, - bundle_filename - ) - os.remove(tmp_file) - bundle_size = os.stat(bundle_filename).st_size - print(f'Combined size: {bundle_size}') - - minify( - bundle_filename, - mini_filename, - os.path.join(html_tmp_dir, 'minify.json') - ) - mini_size = os.stat(mini_filename).st_size - print(f'Minified size: {mini_size}') - - html_to_url( - mini_filename, - url_filename - ) - - url_size = os.stat(url_filename).st_size - print(f'URL data size: {url_size} of 2953 (QR capacity)') - - if url_size <= 2953: - url_to_qr( - url_filename, - png_filename - ) - - -if __name__ == '__main__': - main() +import os +import shutil +import argparse +import subprocess +import qrcode +from qrcode.image.styledpil import StyledPilImage +import qrcode.constants + + +def html_to_url(html_filename, txt_filename): + with open(html_filename, 'rb') as f_html: + html_text = f_html.read().decode('utf-8') + + print(f'HTML src size: {len(html_text)}') + + html_text_escaped = html_text + + # % operator (division reminder) is rarely used in JS + # but can still appear and mess up percent-encoding of Data URL. + html_text_escaped = html_text_escaped.replace('%', '%' + '25') + + # You might think urllib.parse.quote is a better solution, + # but it escapes too much stuff and bloats data, + # significantly reducing effective QR capacity + html_text_escaped = html_text_escaped.replace('#', '%' + '23') + + print(f'HTML rdy size: {len(html_text_escaped)}') + data_url = 'data:text/html,' + html_text_escaped + qr_data_encoded = data_url.encode('ascii') + with open(txt_filename, 'wb') as f_txt: + f_txt.write(qr_data_encoded) + + +def url_to_qr(url_filename, png_filename): + qr_code = qrcode.QRCode( + version=None, + image_factory=StyledPilImage, + border=4, + box_size=2, + error_correction=qrcode.constants.ERROR_CORRECT_L, + ) + with open(url_filename, 'rb') as f_url: + html_text = f_url.read().decode('utf-8') + qr_code.add_data(html_text) + print('QR data dump:') + for i in qr_code.data_list: + print(f'- bytes={len(i.data)} mode={i.mode} content={i.data[:64]}') + print(f'QR code fit size: {qr_code.best_fit()}') + qr_code.make(fit=True) + img = qr_code.make_image() + img.save(png_filename) + + +def inline_scripts(html_filename, out_filename): + subprocess.check_call([ + 'inline-script-tags', + html_filename, + out_filename]) + + +def inline_stylesheets(html_filename, out_filename): + subprocess.check_call([ + 'inline-stylesheets', + html_filename, + out_filename]) + + +def minify(html_filename, out_filename, config_file): + subprocess.check_call([ + 'html-minifier', + '--config-file', config_file, + html_filename, + '--output', out_filename]) + + +def uglify(html_dir, html_tmp_dir): + def js_filter(file_name): + return file_name.endswith('.js') + list_dir = os.listdir(html_dir) + js_files = list( + os.path.join(html_dir, x) for x in filter(js_filter, list_dir)) + js_tmp_files = list( + os.path.join(html_tmp_dir, x) for x in filter(js_filter, list_dir)) + js_pairs = zip(js_files, js_tmp_files) + for js_pair in js_pairs: + subprocess.check_call([ + 'uglifyjs', + '--output', js_pair[1], + '--warn', + '--config-file', os.path.join(html_dir, 'uglify.json'), + '--', js_pair[0]]) + + +def parse_args(): + parser = argparse.ArgumentParser('Convert HTML page into QR code') + parser.add_argument('--htmldir', + help='directory with html page') + parser.add_argument('--builddir', + help='directory to put bundle and QR code into') + return parser.parse_args() + + +def main(): + args = parse_args() + + html_tmp_dir = os.path.basename(args.htmldir) + '.tmp' + app_name = os.path.basename(args.htmldir) + deploy_directory = os.path.join(args.builddir, app_name) + if not os.path.isdir(deploy_directory): + os.makedirs(deploy_directory) + + bundle_filename = os.path.join(html_tmp_dir, 'index.html') + mini_filename = os.path.join(deploy_directory, 'index.html') + url_filename = os.path.join(deploy_directory, 'qr.txt') + png_filename = os.path.join(deploy_directory, 'qr.png') + + if os.path.isdir(html_tmp_dir): + shutil.rmtree(html_tmp_dir) + shutil.copytree(args.htmldir, html_tmp_dir) + uglify(args.htmldir, html_tmp_dir) + + tmp_file = os.path.join(html_tmp_dir, 'index.html.tmp') + inline_scripts( + os.path.join(html_tmp_dir, 'index.html'), + tmp_file + ) + inline_stylesheets( + tmp_file, + bundle_filename + ) + os.remove(tmp_file) + bundle_size = os.stat(bundle_filename).st_size + print(f'Combined size: {bundle_size}') + + minify( + bundle_filename, + mini_filename, + os.path.join(html_tmp_dir, 'minify.json') + ) + mini_size = os.stat(mini_filename).st_size + print(f'Minified size: {mini_size}') + + html_to_url( + mini_filename, + url_filename + ) + + url_size = os.stat(url_filename).st_size + print(f'URL data size: {url_size} of 2953 (QR capacity)') + + if url_size <= 2953: + url_to_qr( + url_filename, + png_filename + ) + + +if __name__ == '__main__': + main() diff --git a/qrs.py b/qrs.py index b973f11..984f8a4 100644 --- a/qrs.py +++ b/qrs.py @@ -16,7 +16,7 @@ def parse_args(): def main(): args = parse_args() - for package in os.listdir(args.htmldirs): + for package in sorted(os.listdir(args.htmldirs)): print(f'Processing package {package}') subprocess.check_call([ sys.executable, 'qr.py', diff --git a/requirements-contrib.txt b/requirements-contrib.txt index b989fb4..1548e8d 100644 --- a/requirements-contrib.txt +++ b/requirements-contrib.txt @@ -1 +1 @@ -prospector==1.10.3 +ruff==0.15.22