Compare commits

..

128 commits

Author SHA1 Message Date
Yassine Doghri
bc041702dd
docs: update CODE_OF_CONDUCT.md to be based on Contributor Covenant 3.0 2026-03-01 12:40:08 +00:00
Yassine Doghri
c13bbdffdf
fix(docker): add arch-specific supercronic and s6-overlay services
fix #580
2026-02-24 21:04:00 +01:00
Yassine Doghri
aad17646f1
build(docs): change wrong Aside type from warning to caution in docker page 2026-02-19 23:15:44 +00:00
Yassine Doghri
385a3cb13a
ci(docker): edit CI Dockerfile to install pnpm using corepack 2026-02-19 21:44:03 +00:00
Yassine Doghri
ed57e13b40
feat: set min PHP version to 8.5 + upgrade CI4 to 4.7
update all dependencies to latest
2026-02-19 16:23:20 +00:00
Yassine Doghri
6b302ad8bf
fix(player): load icons locally instead of relying on vimejs picking them from third party scripts
closes #551
2026-02-19 13:16:55 +00:00
Yassine Doghri
cc86ce030f
fix(emails): display verification link in clear text for email clients only displaying text
closes #328
2026-02-19 12:44:54 +00:00
Yassine Doghri
3943441683
build(docker): set major version channel tags to prevent major breaking changes (1, 2-next, ...) 2026-02-19 12:44:23 +00:00
Yassine Doghri
b747967a18
build(docker): remove --progress flag for rsync when bundling castopod
this reduces logs verbosity for easier debugging
2026-02-17 22:22:26 +01:00
Yassine Doghri
a585362827
chore: update CI files after update from 4.6.3 to 4.6.5 2026-02-17 21:09:55 +00:00
Yassine Doghri
abf214757c
docs(docker): add tip to encourage users to pin the castopod image version during production 2026-02-17 21:04:11 +00:00
Yassine Doghri
f01de13637
build(docker): push amd64 image before overwriting manifest with both amd64 and arm64 platforms 2026-02-17 22:02:46 +01:00
Yassine Doghri
77826552f1
fix(docker): create optimized builder with docker-container driver for arm64 builds
closes #580
2026-02-17 20:32:17 +01:00
Yassine Doghri
e5fb676cb6
feat(docker): replace all-in-one image with FrankenPHP and Caddy based image + discard other images
- use serversideup/php as a base image
- remove nginx unit base
- remove app / webserver images
- add bundle stage to remove pipeline dependency
- update docker setup docs
- edit gitlabci rules and release logic
2026-02-17 19:31:24 +00:00
Yassine Doghri
49a43d08cc fix(fediverse): match episode posts replies fields with comments in union query
fixes #577
2025-12-20 18:54:19 +00:00
Andreas Grupp
89d0fe4a7e fix(fediverse): access to URI in 'object' instead of going down with '->id' in delete case 2025-11-03 10:35:35 +01:00
kloo kloo
950d42c838 fix: edit Podcast.php to clarify the followers are Fediverse followers 2025-10-13 12:37:17 +00:00
kloo kloo
6be7a1f4d7 fix: edit Platforms.php to alphabetize all broadcast platforms + minor UI labels edits 2025-10-13 12:36:51 +00:00
Paul Cutler
4c46c15e39 docs: update docs to expand Persons information and fix broken chapters.json link 2025-10-13 12:36:37 +00:00
Yassine Doghri
bbfaa1bfc3 chore: update php and js dependencies to latest 2025-10-07 13:32:48 +00:00
Yassine Doghri
85503ee282 docs(plugins): clear up some ideas and fix links
update castopod-plugins-manager + other dependencies to latest
2025-10-06 16:53:59 +00:00
Yassine Doghri
265cbbac09 fix(bundle): edit rsync filter to include resources/icons directory 2025-10-03 13:07:26 +00:00
Yassine Doghri
e291b6239c docs(plugins): add install and share instructions with the official plugins repository for discovery 2025-10-03 12:51:03 +00:00
Yassine Doghri
40f671c8b6 docs: add plugin manifest schema definition as a page in the docs root
update js and php dependencies
2025-09-30 19:31:15 +00:00
Yassine Doghri
3d0db5c64a feat(plugins): add spark commands to install, add, update and remove plugins using adaures' cpm
update js & php dependencies to latest and fix rector, phpstan and ecs issues
2025-09-22 17:34:36 +00:00
Yassine Doghri
b5a403b990 chore: replace twitter links by bluesky in docs
+ update dependencies to latest
2025-08-31 09:48:01 +00:00
Yassine Doghri
835f099f2e docs: update starlight to 0.35.2 + update docker images to latest 2025-08-29 10:37:01 +00:00
Yassine Doghri
9dffc8d5f1 ci(php-icons): fix local icon sets path 2025-08-26 08:10:36 +00:00
Yassine Doghri
8ec42c33ff fix(fediverse): add is_private field to posts to flag private posts and hide them from public views 2025-08-25 18:32:09 +00:00
Yassine Doghri
346c00e7b5 chore: update CI to v4.6.3 + all php and js dependencies 2025-08-25 18:09:41 +00:00
kloo kloo
96b2df15b0 chore: add discourse social network 2025-05-20 14:23:01 +02:00
Paul Cutler
61d6a6b60f docs: fix broken note using Aside tag 2025-05-20 13:32:24 +02:00
Yassine Doghri
00870ceff2 ci: skip ssl when connecting to mariadb test database 2025-03-14 13:45:36 +00:00
Yassine Doghri
31fee52208 docs: update database and php requirements to LTS versions 2025-03-14 13:44:55 +00:00
Yassine Doghri
567d5e01a3 feat(plugins): add submodule boolean property to manifest schema 2025-03-14 13:02:55 +00:00
Yassine Doghri
94cea0ce91 feat: set min PHP version to 8.4
update CI4 to 4.6.0 + use codeigniter-vite and vite-plugin-codeigniter to load assets
2025-03-14 12:54:51 +00:00
Paul Cutler
0e4e301b81 docs: fix broken Note in instance / import podcast by using Aside tag 2025-02-27 15:52:36 +00:00
Paul Cutler
f8fb25f52d docs: update docs with typo fixes 2025-02-27 15:48:42 +00:00
Paul Cutler
93b4741333 docs: update CONTRIBUTING-DEV.md with how to update documentation 2025-02-27 15:43:14 +00:00
Yassine Doghri
5578104207 ci(docs): update pnpm install script to v10 2025-02-27 11:44:56 +00:00
Yassine Doghri
5dce8cb949 fix: update api schema to pass form data when publishing an episode
closes #553
2025-02-27 11:09:45 +00:00
Yassine Doghri
1e6477db67 docs(readme): update logo & sponsor images + all-contributors list 2025-01-04 11:28:21 +00:00
Yassine Doghri
0265775177 fix(analytics): edit permission filters to include podcast id in routes 2024-12-30 15:50:05 +00:00
semantic-release-bot
c9fabe8888 chore(release): 2.0.0-next.3 [skip ci]
## [2.0.0-next.3](https://code.castopod.org/adaures/castopod/compare/v2.0.0-next.2...v2.0.0-next.3) (2024-12-30)

### Features

* **api:** add Episode create and publish endpoints ([a90cdfd](a90cdfdcdb))
* **image:** add image size's width and height ([f50098e](f50098ec89))
* **plugins:** add defaultValue for all field types ([d3a98db](d3a98db6d0))
* **plugins:** add group field type + multiple option to render field arrays ([11ccd0e](11ccd0ebe7))
* **plugins:** add html field type + CodeEditor component + rework html head generation ([8cf9c6d](8cf9c6dc83))
* **rss:** add option for 301 redirect to new feed url ([8402cc2](8402cc29d2))

### Bug Fixes

* add downloads_count to episodes table, computed every hour ([f981937](f981937645))
* allow passing json to app.proxyIPs config to set it ([cbf739e](cbf739e95c))
* **api:** cast integers when creating episode ([775b302](775b302f7c))
* **docker-image:** clear cache to account for new assets and data structure changes ([63c763f](63c763f941)), closes [#510](https://code.castopod.org/adaures/castopod/issues/510)
* edit remap functions to get episode in episode admin controllers ([9f74cca](9f74cca342))
* **episode:** do not change slug when editing episode title ([a83afb0](a83afb0004)), closes [#513](https://code.castopod.org/adaures/castopod/issues/513)
* **fediverse:** add "processing" and "failed" statuses to better manage broadcast load ([1d7583d](1d7583d738)), closes [#511](https://code.castopod.org/adaures/castopod/issues/511)
* **icons:** set correct names for lock and lock-unlock icons in premium banner ([37ee6d3](37ee6d35b4))
* **plugins:** clear cache after activating or deactivating plugin ([08c7df2](08c7df2a5d))
* **plugins:** delete relevant cache when submitting settings ([00bd4c0](00bd4c02ee))
* **podcast-model:** always query podcast from database when clearing cache ([d30c49c](d30c49cdff))
* **premium-podcasts:** update query to validate subscription ([2b1bbf3](2b1bbf3430))
* **preview:** delete episode preview cache after editing episode ([732d429](732d42923d)), closes [#514](https://code.castopod.org/adaures/castopod/issues/514)
* **release:** add conventional-changelog-conventionalcommits for CHANGELOG generation ([6934c8a](6934c8aa8f))
* **rss:** add subscription id to cache name to prevent premium feeds from overlapping ([74f9325](74f9325946))
* set user as www-data when running cron jobs in docker's supervisord config ([65d74f1](65d74f14e6))
* typo in EpisodeController remap function to get episode ([f288a75](f288a750f5))
* update select and multi-select options to value/label arrays ([63f93f5](63f93f585b))

### Internal

* **plugins:** create Field objects per field type in settings forms + handle rendering in class ([34be5bc](34be5bccab))
* remove fields from podcast and episode entities to be replaced with plugins ([b869acb](b869acb3a9))
* rename controller methods for views and actions to be more consistent ([85704bf](85704bfbe0))
* update CodeIgniter to v4.5.6 ([f295e9a](f295e9aa4c))
* update codigniter-icons to v1.0.1 ([fa6967e](fa6967e65c))
* update js dependencies to latest ([70c9797](70c97971fc))
2024-12-30 12:31:21 +00:00
Yassine Doghri
6934c8aa8f fix(release): add conventional-changelog-conventionalcommits for CHANGELOG generation 2024-12-30 12:21:30 +00:00
Yassine Doghri
70c97971fc chore: update js dependencies to latest 2024-12-30 12:02:51 +00:00
Yassine Doghri
9f74cca342 fix: edit remap functions to get episode in episode admin controllers 2024-12-29 16:06:00 +00:00
Yassine Doghri
f295e9aa4c chore: update CodeIgniter to v4.5.6
+ update php dependencies to latest
2024-12-29 16:02:08 +00:00
Yassine Doghri
fc2e7a0d83 docs(api): add instructions to enable and use API 2024-12-29 14:03:23 +00:00
Yassine Doghri
f981937645 fix: add downloads_count to episodes table, computed every hour
This removes computing latency when retrieving episodes list with download count in admin.
The more
analytics records, the more it took to calculate the sum of hits to get the downloads count for each
episode.
2024-12-29 13:24:42 +00:00
Yassine Doghri
f288a750f5 fix: typo in EpisodeController remap function to get episode
- fix defaultValue being empty string when cast as array
- fix initial styles for select to reduce
content layout shift
2024-12-29 13:21:50 +00:00
Yassine Doghri
7e8f0003d1 build(release): update semantic-release config to include internal changes
[ci skip]
2024-12-26 13:41:27 +00:00
Yassine Doghri
888d610c2d docs(api): add available operations based on openapi schema
use starlight-openapi plugin to generate docs

closes #536
2024-12-26 13:01:53 +00:00
Nate Ritter
775b302f7c fix(api): cast integers when creating episode 2024-12-25 11:29:11 +00:00
Yassine Doghri
09256b4eb7 docs(user-guide): update links for consistency + remove missing monetization links 2024-12-25 11:28:51 +00:00
Paul Cutler
0736050d1a docs: add user guide section 2024-12-25 11:26:34 +00:00
Nate Ritter
a90cdfdcdb feat(api): add Episode create and publish endpoints 2024-12-25 11:22:29 +00:00
Yassine Doghri
8402cc29d2 feat(rss): add option for 301 redirect to new feed url 2024-12-25 11:22:13 +00:00
Yassine Doghri
08c7df2a5d fix(plugins): clear cache after activating or deactivating plugin 2024-12-23 16:09:17 +00:00
Yassine Doghri
34be5bccab refactor(plugins): create Field objects per field type in settings forms + handle rendering in class
update manifest.schema.json to have defaultValue type differ based on field type
2024-12-23 15:35:47 +00:00
Yassine Doghri
d3a98db6d0 feat(plugins): add defaultValue for all field types 2024-12-19 12:33:57 +00:00
Yassine Doghri
00bd4c02ee fix(plugins): delete relevant cache when submitting settings 2024-12-18 17:50:33 +00:00
Yassine Doghri
85704bfbe0 refactor: rename controller methods for views and actions to be more consistent
add PermalinkEditor component
2024-12-18 16:05:25 +00:00
Yassine Doghri
8cf9c6dc83 feat(plugins): add html field type + CodeEditor component + rework html head generation
update php and js packages to latest
2024-12-17 15:11:45 +00:00
Yassine Doghri
b869acb3a9 refactor: remove fields from podcast and episode entities to be replaced with plugins 2024-12-15 17:34:36 +00:00
Yassine Doghri
11ccd0ebe7 feat(plugins): add group field type + multiple option to render field arrays
- update docs
- render hint and helper options for all fields
- replace option's hint with
description
2024-12-10 15:57:06 +00:00
Yassine Doghri
f50098ec89 feat(image): add image size's width and height
escape plugin description + replace codeigniter-icons with php-icons v1.2
2024-11-07 12:56:46 +00:00
Paul Cutler
77e55835c0 docs: update command to run vite dev server 2024-11-06 13:19:59 +00:00
Yassine Doghri
fa6967e65c refactor: update codigniter-icons to v1.0.1 2024-11-06 13:19:59 +00:00
Paul Cutler
ea720e01ba docs: update Contributing docs to fix broken link and update spelling and grammar 2024-11-06 13:19:59 +00:00
Yassine Doghri
cbf739e95c fix: allow passing json to app.proxyIPs config to set it 2024-11-06 13:19:59 +00:00
Yassine Doghri
63f93f585b fix: update select and multi-select options to value/label arrays
add hint to select options + update dependencies to latest
2024-11-06 13:19:59 +00:00
Yassine Doghri
65d74f14e6 fix: set user as www-data when running cron jobs in docker's supervisord config
This prevents any ownership issue when cron tasks create cache files
2024-11-06 13:19:59 +00:00
Yassine Doghri
1667f5b202 build: update CI4 to v4.5.5 + php and js packages to latest 2024-11-06 13:19:59 +00:00
Yassine Doghri
1d7583d738 fix(fediverse): add "processing" and "failed" statuses to better manage broadcast load
fixes #511
2024-11-06 13:19:59 +00:00
Yassine Doghri
d30c49cdff fix(podcast-model): always query podcast from database when clearing cache
this prevents from having any unexpected caching side effects
2024-11-06 13:19:59 +00:00
Yassine Doghri
a83afb0004 fix(episode): do not change slug when editing episode title
fixes #513
2024-11-06 13:19:59 +00:00
Yassine Doghri
732d42923d fix(preview): delete episode preview cache after editing episode
fixes #514
2024-11-06 13:19:59 +00:00
Yassine Doghri
63c763f941 fix(docker-image): clear cache to account for new assets and data structure changes
fixes #510
2024-11-06 13:19:58 +00:00
Yassine Doghri
a68959c906 build: update CI4 to 4.5.4 + php and js dependencies to latest 2024-11-06 13:19:58 +00:00
Yassine Doghri
74f9325946 fix(rss): add subscription id to cache name to prevent premium feeds from overlapping 2024-11-06 13:19:58 +00:00
Yassine Doghri
2b1bbf3430 fix(premium-podcasts): update query to validate subscription 2024-11-06 13:19:58 +00:00
Yassine Doghri
37ee6d35b4 fix(icons): set correct names for lock and lock-unlock icons in premium banner 2024-11-06 13:19:58 +00:00
semantic-release-bot
3cd30205d9 chore(release): 2.0.0-next.2 [skip ci]
# [2.0.0-next.2](https://code.castopod.org/adaures/castopod/compare/v2.0.0-next.1...v2.0.0-next.2) (2024-07-08)

### Bug Fixes

* **audio-player:** set player icons to default instead of missing Castopod's ([0ba0a25](0ba0a25b11))
* broken icon call in frontend default pages template ([3228362](322836254e))
* **manifest:** set repository url as required in docstring typings ([a8c81b3](a8c81b3fa1))
* set correct icons parameters in map and funding links views ([5d35524](5d35524875)), closes [#500](https://code.castopod.org/adaures/castopod/issues/500)

### Features

* **plugins:** add `minCastopodVersion` to denote incompatibility with previous Castopod versions ([fc9ea75](fc9ea7597e))
* **plugins:** load and display LICENSE.md file if found in plugin's directory ([fee7905](fee7905935))
2024-07-08 16:12:36 +00:00
Yassine Doghri
53232d3b61 docs(security): add disclaimer aside to third-party plugins section 2024-07-08 14:06:56 +00:00
Yassine Doghri
7405f8897d docs(security): add disclaimer for third-party plugins and how to mitigate potential security risks 2024-07-05 17:43:02 +00:00
Yassine Doghri
fc9ea7597e feat(plugins): add minCastopodVersion to denote incompatibility with previous Castopod versions 2024-07-05 16:47:01 +00:00
Yassine Doghri
fee7905935 feat(plugins): load and display LICENSE.md file if found in plugin's directory 2024-07-05 16:44:35 +00:00
Yassine Doghri
1a439083a2 docs: fix typo in comments in auth file 2024-07-04 15:54:17 +00:00
Yassine Doghri
0ba0a25b11 fix(audio-player): set player icons to default instead of missing Castopod's 2024-07-04 14:44:17 +00:00
Yassine Doghri
c21864ee25 docs: add "latest" option to DocsVersionSelect based on main branch 2024-07-04 13:54:59 +00:00
crowdin
1c5fe1fea6 chore(i18n): new Crowdin updates 2024-07-04 13:54:43 +00:00
Yassine Doghri
a8c81b3fa1 fix(manifest): set repository url as required in docstring typings 2024-07-04 13:32:44 +00:00
Aonrud
322836254e fix: broken icon call in frontend default pages template 2024-07-04 13:27:36 +00:00
Yassine Doghri
e9c04548de build: update CI to 4.5.3 + php and js dependencies to latest 2024-07-04 13:27:10 +00:00
Yassine Doghri
5d35524875 fix: set correct icons parameters in map and funding links views
fixes #500
2024-07-04 13:26:59 +00:00
Yassine Doghri
7a8cd4c730 docs: fix typo for "Introduction" label 2024-07-04 13:26:43 +00:00
Yassine Doghri
5339669ea6 build(composer): update version 2.0.0-next to be 2.0.0-dev in composer.json 2024-07-04 13:15:00 +00:00
semantic-release-bot
0eeedb9dc6 chore(release): 2.0.0-next.1 [skip ci]
# [2.0.0-next.1](https://code.castopod.org/adaures/castopod/compare/v1.11.0...v2.0.0-next.1) (6/19/2024)

### Bug Fixes

* add missing php-icons config file to bundle ([56612f0](56612f0c76))
* **docs:** add base to og image using env variable ([fe67659](fe676590f2))
* **import:** rewrite download_file helper to output curl response directly to file ([eb7ad2f](eb7ad2f7e1))
* include app/Resources/icons folder to bundle ([3fd5efc](3fd5efc795))
* **platforms:** add platforms service + reduce memory consumption when rendering platform cards ([fe73e9f](fe73e9fae9))
* set owner email visibility when editing podcast ([fc4f982](fc4f982556)), closes [#473](https://code.castopod.org/adaures/castopod/issues/473)

### Build System

* release next major version as prerelease ([8275226](827522643e))

### Features

* add Plugins module with base files for plugins architecture ([7253e13](7253e13ac2))
* **plugins:** abstract settings form for general, podcast and episode types ([b62b483](b62b483ad9))
* **plugins:** activate / deactivate plugin using settings table ([27d2a1b](27d2a1b0ff))
* **plugins:** add aside with plugin metadata next to plugin's readme ([dfb7888](dfb7888aeb))
* **plugins:** add before channel/item hooks to allow podcast/episode data edit when generating rss ([80d2c48](80d2c48ee2))
* **plugins:** add json schema definition for plugin manifest ([b5eddf3](b5eddf351f))
* **plugins:** add methods to easily retrieve general, podcast and episode settings in hooks methods ([3a900bb](3a900bbab6))
* **plugins:** add new field types + validate & cast user data before storing settings ([6f833fc](6f833fc76a))
* **plugins:** add options to manifest for building forms and storing plugin settings ([3d8aedf](3d8aedf9c3))
* **plugins:** add settings page for podcast and episode if defined in the plugin's manifest ([89ac92f](89ac92fb41))
* **plugins:** add siteHead hook to add custom meta tags to public pages ([e80a33b](e80a33bf2a))
* **plugins:** display errors when plugin is invalid instead of crashing ([8ec7909](8ec79097bb))
* **plugins:** handle empty states and long strings in UI ([45ac2a4](45ac2a4be9))
* **plugins:** load and validate plugin manifest.json ([1510e36](1510e36c0a))
* **plugins:** load plugins using file locator service ([587938d](587938d2bf))
* **plugins:** load README.md file to view plugin's instructions in UI ([e6bfdfc](e6bfdfc390))
* **plugins:** register plugins using Plugin.php file instead of namespace + simplify i18n structure ([2035c39](2035c39fd1))
* **plugins:** uninstall plugins via CLI and admin UI ([9a80de4](9a80de4068))
* set owner email to hidden by default in podcast create form ([7a6d9df](7a6d9df6db))
* support podcast:txt tag with verify use case ([57e459e](57e459e187)), closes [#468](https://code.castopod.org/adaures/castopod/issues/468)

### BREAKING CHANGES

* next major release including plugins architecture
2024-06-19 10:12:35 +00:00
Yassine Doghri
827522643e build: release next major version as prerelease
- edit .releaserc + gitlab-ci to add next branch
- add plugins folder to bundle

BREAKING CHANGE: next major release including plugins architecture
2024-06-19 10:00:45 +00:00
Yassine Doghri
e417d45b14 docs(plugins): fill up rest of manifest and hooks reference + creating a plugin 2024-06-14 15:53:33 +00:00
Yassine Doghri
cc6495dc7c refactor(plugins): set settings properties as fields objects 2024-06-14 15:53:33 +00:00
Yassine Doghri
8f8c61eaae docs(plugins): add experimental plugins section + plugins:create command to create plugin via CLI 2024-06-14 15:53:33 +00:00
Yassine Doghri
91dc8c8325 test(plugins): add test suite for Plugins service 2024-06-14 15:53:33 +00:00
Yassine Doghri
3a900bbab6 feat(plugins): add methods to easily retrieve general, podcast and episode settings in hooks methods 2024-06-14 15:53:33 +00:00
Yassine Doghri
2035c39fd1 feat(plugins): register plugins using Plugin.php file instead of namespace + simplify i18n structure 2024-06-14 15:53:33 +00:00
Yassine Doghri
d7b9730d7e docs(ci): build and deploy docs for next branch 2024-06-14 15:53:33 +00:00
Yassine Doghri
b5bd2db28f build(php): upgrade min php version to 8.3 2024-06-14 15:53:33 +00:00
Yassine Doghri
e2a90def88 test(plugins): add test cases for loading manifest data 2024-06-14 15:53:33 +00:00
Yassine Doghri
014facd5a1 refactor(plugins): rename manifest schema 2024-06-14 15:53:33 +00:00
Yassine Doghri
80d2c48ee2 feat(plugins): add before channel/item hooks to allow podcast/episode data edit when generating rss 2024-06-14 15:53:33 +00:00
Yassine Doghri
8ec79097bb feat(plugins): display errors when plugin is invalid instead of crashing 2024-06-14 15:53:33 +00:00
Yassine Doghri
45ac2a4be9 feat(plugins): handle empty states and long strings in UI 2024-06-14 15:53:33 +00:00
Yassine Doghri
b62b483ad9 feat(plugins): abstract settings form for general, podcast and episode types
update filter permission logic for replacing router param
2024-06-14 15:53:33 +00:00
Yassine Doghri
6f833fc76a feat(plugins): add new field types + validate & cast user data before storing settings
+ refactor form fields components
2024-06-14 15:53:33 +00:00
Yassine Doghri
82714e7155 style(buttons): add tint to variants 2024-06-14 15:53:33 +00:00
Yassine Doghri
dfb7888aeb feat(plugins): add aside with plugin metadata next to plugin's readme
- enhance plugin card ui
- refactor components to be more consistent
- invert toggler label for better UX
- edit view components regex
2024-06-14 15:53:33 +00:00
Yassine Doghri
e6bfdfc390 feat(plugins): load README.md file to view plugin's instructions in UI 2024-06-14 15:53:32 +00:00
Yassine Doghri
1510e36c0a feat(plugins): load and validate plugin manifest.json 2024-06-14 15:53:32 +00:00
Yassine Doghri
b5eddf351f feat(plugins): add json schema definition for plugin manifest 2024-06-14 15:53:32 +00:00
Yassine Doghri
896f00661f refactor(plugins): redefine plugins folder structure to vendor/package 2024-06-14 15:53:32 +00:00
Yassine Doghri
9a80de4068 feat(plugins): uninstall plugins via CLI and admin UI 2024-06-14 15:53:32 +00:00
Yassine Doghri
89ac92fb41 feat(plugins): add settings page for podcast and episode if defined in the plugin's manifest
- rename options to settings
2024-06-14 15:53:32 +00:00
Yassine Doghri
3d8aedf9c3 feat(plugins): add options to manifest for building forms and storing plugin settings 2024-06-14 15:53:32 +00:00
Yassine Doghri
e80a33bf2a feat(plugins): add siteHead hook to add custom meta tags to public pages 2024-06-14 15:53:32 +00:00
Yassine Doghri
27d2a1b0ff feat(plugins): activate / deactivate plugin using settings table
+ load plugin icon
+ add pagination
+ autoload plugins in Config/Autoload.php to handle plugin
i18n
+ style plugin cards
2024-06-14 15:53:32 +00:00
Yassine Doghri
587938d2bf feat(plugins): load plugins using file locator service 2024-06-14 15:53:32 +00:00
Yassine Doghri
7253e13ac2 feat: add Plugins module with base files for plugins architecture 2024-06-14 15:53:32 +00:00
890 changed files with 18802 additions and 16177 deletions

View file

@ -3,17 +3,15 @@
"projectOwner": "adaures",
"repoType": "gitlab",
"repoHost": "https://code.castopod.org",
"files": ["README.md", "docs/src/content/docs/en/index.mdx"],
"files": ["README.md"],
"imageSize": 100,
"commit": false,
"contributorsPerLine": 7,
"wrapperTemplate": "\n<table class=\"all-contributors-table\">\n <tbody><%= bodyContent %> </tbody>\n<%= tableFooterContent %></table>\n\n",
"contributors": [
{
"login": "yassinedoghri",
"name": "Yassine Doghri",
"avatar_url": "https://code.castopod.org/uploads/-/system/user/avatar/3/avatar.png",
"profile": "https://github.com/yassinedoghri",
"avatar_url": "https://avatars.githubusercontent.com/u/11021441?v=4",
"profile": "https://yassinedoghri.com",
"contributions": [
"code",
"bug",
@ -472,18 +470,6 @@
}
]
},
{
"login": "ghose",
"name": "ghose (XoseM)",
"avatar_url": "https://crowdin-static.downloads.crowdin.com/avatar/12617257/large/a201650da44fed28890b0e0d8477a663.jpg",
"profile": "https://crowdin.com/profile/xosem",
"contributions": [
{
"type": "translation",
"url": "https://translate.castopod.org"
}
]
},
{
"login": "basen1982",
"name": "Andreas Olsson",
@ -545,7 +531,7 @@
]
},
{
"login": "ahmed.sabouni11",
"login": "ahmedsabouni",
"name": "Ahmed Sabouni",
"avatar_url": "https://avatars.githubusercontent.com/u/74497842?v=4",
"profile": "https://github.com/ahmedsabouni",
@ -564,11 +550,25 @@
"contributions": ["code"]
},
{
"login": "NeoluxConsulting",
"login": "Dwev",
"name": "Guy Martin",
"avatar_url": "https://secure.gravatar.com/avatar/6e745565356330c1e29a85d52bffdaa1?s=80&d=identicon",
"profile": "https://code.castopod.org/NeoluxConsulting",
"avatar_url": "https://avatars.githubusercontent.com/u/46626050?v=4",
"profile": "https://github.com/Dwev",
"contributions": ["bug", "code"]
},
{
"login": "prcutler",
"name": "Paul Cutler",
"avatar_url": "https://avatars.githubusercontent.com/u/67276?v=4",
"profile": "https://github.com/prcutler",
"contributions": ["doc", "question", "ideas"]
},
{
"login": "nateritter",
"name": "Nate Ritter",
"avatar_url": "https://avatars.githubusercontent.com/u/198798?v=4",
"profile": "https://github.com/nateritter",
"contributions": ["code"]
}
],
"commitConvention": "none"

View file

@ -4,7 +4,7 @@
# ⚠️ NOT optimized for production
# should be used only for development purposes
#---------------------------------------------------
FROM php:8.2-fpm
FROM php:8.5-fpm
LABEL maintainer="Yassine Doghri <yassine@doghri.fr>"
@ -47,11 +47,4 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& docker-php-ext-enable redis \
# mysqli for database access
&& docker-php-ext-install mysqli \
&& docker-php-ext-enable mysqli \
# configure php
&& echo "file_uploads = On\n" \
"memory_limit = 512M\n" \
"upload_max_filesize = 500M\n" \
"post_max_size = 512M\n" \
"max_execution_time = 300\n" \
> /usr/local/etc/php/conf.d/uploads.ini
&& docker-php-ext-enable mysqli

View file

@ -6,7 +6,7 @@
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"postCreateCommand": "composer install && pnpm install && pnpm run build:static && php spark migrate --all && php spark db:seed DevSeeder",
"postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && crontab .devcontainer/crontab && cron && php spark serve --host 0.0.0.0",
"postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && crontab .devcontainer/crontab && cron && php spark serve --host 0.0.0.0 --port ${APP_PORT:-8080}",
"postAttachCommand": "crontab .devcontainer/crontab && service cron reload",
"shutdownAction": "stopCompose",
"features": {
@ -30,7 +30,17 @@
"spark": "php",
"env": "dotenv",
".rsync-filter": "diff"
}
},
"json.schemas": [
{
"fileMatch": [
"plugins/**/manifest.json",
"tests/modules/Plugins/mocks/manifests/*.json",
"tests/modules/Plugins/mocks/plugins/**/manifest.json"
],
"url": "/workspaces/castopod/modules/Plugins/Manifest/manifest.schema.json"
}
]
},
"extensions": [
"astro-build.astro-vscode",
@ -52,7 +62,8 @@
"stylelint.vscode-stylelint",
"unifiedjs.vscode-mdx",
"wayou.vscode-todo-highlight",
"yzhang.markdown-all-in-one"
"yzhang.markdown-all-in-one",
"42Crunch.vscode-openapi"
]
}
}

View file

@ -1,20 +1,19 @@
version: "3"
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- 8080:8080
volumes:
- ../..:/workspaces:cached
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
environment:
APP_PORT: ${APP_PORT:-8080} # used in devcontainer.json file
VITE_PORT: ${VITE_PORT:-5173} # used in ../vite.config.js file
CI_ENVIRONMENT: development
vite_environment: development
app_forceGlobalSecureRequests: 0 #false
app_baseURL: http://localhost:8080/
media_baseURL: http://localhost:8080/
app_baseURL: http://localhost:${APP_PORT:-8080}/
media_baseURL: http://localhost:${APP_PORT:-8080}/
admin_gateway: cp-admin
auth_gateway: cp-auth
analytics_salt: dev_analytics_salt
@ -29,16 +28,10 @@ services:
email_SMTPHost: mailpit
email_SMTPUser: castopod
email_SMTPPass: castopod
email_SMTPPort: 1025
email_SMTPPort: ${MAILPIT_SMTP_PORT:-1025}
depends_on:
- redis
- mariadb
redis:
image: redis:alpine
volumes:
- redis:/data
mariadb:
image: mariadb:10.2
volumes:
@ -69,8 +62,8 @@ services:
volumes:
- mailpit:/data
ports:
- 8025:8025
- 1025:1025
- ${MAILPIT_WEBUI_PORT:-8025}:8025
- ${MAILPIT_SMTP_PORT:-1025}:1025
environment:
MP_MAX_MESSAGES: 5000
MP_DATA_FILE: /data/mailpit.db
@ -78,7 +71,6 @@ services:
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes:
redis:
mariadb:
phpmyadmin:
mailpit:

View file

@ -0,0 +1,5 @@
file_uploads = On
memory_limit = 512M
upload_max_filesize = 500M
post_max_size = 512M
max_execution_time = 300

6
.gitignore vendored
View file

@ -179,3 +179,9 @@ modules/Admin/Language/*/PersonsTaxonomy.php
castopod/
castopod-*.zip
castopod-*.tar.gz
# Plugins
plugins/*
!plugins/.gitkeep
writable/plugins.json
writable/plugins-lock.json

View file

@ -1,4 +1,4 @@
image: code.castopod.org:5050/adaures/castopod:ci-php8.1
image: code.castopod.org:5050/adaures/castopod:ci-php8.5
stages:
- prepare
@ -94,14 +94,14 @@ lint-js:
tests:
stage: quality
services:
- mariadb:10.2
- mariadb:10.11
variables:
MYSQL_ROOT_PASSWORD: "R00Tp4ssW0RD"
MYSQL_DATABASE: "test"
MYSQL_USER: "castopod"
MYSQL_PASSWORD: "castopod"
script:
- echo "SHOW DATABASES;" | mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host=mariadb "$MYSQL_DATABASE" --skip-ssl
- echo "SHOW DATABASES;" | mariadb --user=root --password="$MYSQL_ROOT_PASSWORD" --host=mariadb "$MYSQL_DATABASE" --skip_ssl
# run phpunit without code coverage
# TODO: add code coverage
@ -135,7 +135,7 @@ bundle:
rules:
- if: $CI_PROJECT_NAMESPACE != "adaures"
when: never
- if: $CI_COMMIT_BRANCH =~ /^(main|beta|alpha|next)$/ || $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH =~ /^(main|alpha|beta|next)$/ || $CI_COMMIT_TAG
when: never
- when: on_success

View file

@ -6,7 +6,7 @@
1. [First step]
2. [Second step]
3. [and so on...]
3. [and so on]
### Expected behavior
@ -27,7 +27,7 @@ logs, and code as it's very hard to read otherwise.
- OS: [e.g. Ubuntu server]
- Browser: [e.g. chrome, safari]
- Web server: [eg. Apache]
- [any other relevant context...]
- [any other relevant context]
### Possible fixes

View file

@ -1,7 +1,7 @@
### Is your feature request related to a problem? Please describe
A clear and concise description of what the problem is. Ex. I'm always
frustrated when [...]
frustrated when []
### Describe the solution you'd like

View file

@ -2,7 +2,7 @@
"trailingComma": "es5",
"overrides": [
{
"files": "*.md",
"files": ["*.md", "*.mdx"],
"options": {
"proseWrap": "always"
}

View file

@ -8,6 +8,10 @@
{
"name": "beta",
"prerelease": true
},
{
"name": "next",
"prerelease": true
}
],
"plugins": [

View file

@ -4,6 +4,7 @@
+ resources/
+ app/***
+ modules/***
+ plugins/***
+ public/***
+ themes/***
+ vendor/***

View file

@ -10,10 +10,17 @@
"responsive",
"variants",
"screen",
"layer"
"layer",
"config"
]
}
],
"at-rule-no-deprecated": [
true,
{
"ignoreAtRules": ["apply"]
}
],
"function-no-unknown": [
true,
{

View file

@ -1,234 +1,107 @@
## [1.15.5](https://code.castopod.org/adaures/castopod/compare/v1.15.4...v1.15.5) (2026-02-24)
### Bug Fixes
- **docker:** add arch-specific supercronic and s6-overlay services
([243ce3a](https://code.castopod.org/adaures/castopod/commit/243ce3a45c740352841bf67fe1ff63151f276eb9)),
closes [#580](https://code.castopod.org/adaures/castopod/issues/580)
## [1.15.4](https://code.castopod.org/adaures/castopod/compare/v1.15.3...v1.15.4) (2026-02-19)
### Bug Fixes
- **emails:** display verification link in clear text for email clients only
displaying text
([37c89df](https://code.castopod.org/adaures/castopod/commit/37c89df3424e4f67fb05c09094560df7edbd76d4)),
closes [#328](https://code.castopod.org/adaures/castopod/issues/328)
## [1.15.3](https://code.castopod.org/adaures/castopod/compare/v1.15.2...v1.15.3) (2026-02-19)
### Bug Fixes
- **bundle:** include resources folder for icons during rsync
([70ca0c7](https://code.castopod.org/adaures/castopod/commit/70ca0c7928cabb99a07de01cb4e12f5d81689bbe)),
closes [#588](https://code.castopod.org/adaures/castopod/issues/588)
## [1.15.2](https://code.castopod.org/adaures/castopod/compare/v1.15.1...v1.15.2) (2026-02-18)
### Bug Fixes
- **player:** load icons locally instead of relying on vimejs picking them from
third party scripts
([0961987](https://code.castopod.org/adaures/castopod/commit/096198727627b3dba9c5bd28f20e90cff680316b)),
closes [#551](https://code.castopod.org/adaures/castopod/issues/551)
## [1.15.1](https://code.castopod.org/adaures/castopod/compare/v1.15.0...v1.15.1) (2026-02-17)
### Bug Fixes
- **docker:** create optimized builder with docker-container driver for arm64
builds
([89ae2b8](https://code.castopod.org/adaures/castopod/commit/89ae2b89fd20fa31851a31fe44f514294fbcd688)),
closes [#580](https://code.castopod.org/adaures/castopod/issues/580)
## [1.15.0](https://code.castopod.org/adaures/castopod/compare/v1.14.1...v1.15.0) (2026-02-16)
### Features
- **docker:** replace all-in-one image with FrankenPHP and Caddy based image +
discard other images
([14089f0](https://code.castopod.org/adaures/castopod/commit/14089f0542ccdf187bd64bea8ad2787b9e8c7d59))
## <small>1.14.1 (2026-01-31)</small>
- fix(i18n): set english as first item in supported locales in case locale
negotiation does not work
([44fb904](https://code.castopod.org/adaures/castopod/commit/44fb904))
## 1.14.0 (2026-01-23)
- feat: add Lithuanian and Czech languages
([9582f2a](https://code.castopod.org/adaures/castopod/commit/9582f2a))
## <small>1.13.8 (2025-12-20)</small>
- chore: update CI4 to v4.6.4 + php and js packages to latest
([133e308](https://code.castopod.org/adaures/castopod/commit/133e308))
- fix(fediverse): match episode posts replies fields with comments in union
query ([d438190](https://code.castopod.org/adaures/castopod/commit/d438190)),
closes [#577](https://code.castopod.org/adaures/castopod/issues/577)
## <small>1.13.7 (2025-11-03)</small>
- fix(rss): set person's avatar url to "federation" for width and height of
400px ([a50b0f3](https://code.castopod.org/adaures/castopod/commit/a50b0f3))
## <small>1.13.6 (2025-11-03)</small>
- fix(fediverse): access to URI in 'object' instead of going down with '->id' in
delete case
([41211a1](https://code.castopod.org/adaures/castopod/commit/41211a1))
- build: update docker images' versions + docs to latest
([07e3a9c](https://code.castopod.org/adaures/castopod/commit/07e3a9c))
## <small>1.13.5 (2025-08-25)</small>
- chore: add discourse social network
([08a3779](https://code.castopod.org/adaures/castopod/commit/08a3779))
- chore: fix rector issues with filters' methods return types
([3d7969d](https://code.castopod.org/adaures/castopod/commit/3d7969d))
- chore: update .releaserc to include more detailed release notes
([5b4403e](https://code.castopod.org/adaures/castopod/commit/5b4403e))
- chore: update CI to v4.6.3 + all php and js dependencies
([842c4e4](https://code.castopod.org/adaures/castopod/commit/842c4e4))
- fix(episodes): set dropdown menu for seasons / years to a maximum height with
auto scroll
([f88abd2](https://code.castopod.org/adaures/castopod/commit/f88abd2))
- fix(fediverse): add is_private field to posts to flag private posts and hide
them from public views
([d5ef2ab](https://code.castopod.org/adaures/castopod/commit/d5ef2ab))
## [1.13.4](https://code.castopod.org/adaures/castopod/compare/v1.13.3...v1.13.4) (2025-02-24)
### Bug Fixes
- **platforms:** add podcast id when deleting platforms on save
([019fbaf](https://code.castopod.org/adaures/castopod/commit/019fbaf74ddd7427c3b7dfaef0d2e4409aab0e7c))
- return method instead of throwing a 404 when submitting a post
([44ad651](https://code.castopod.org/adaures/castopod/commit/44ad65117635b6292b4653bca7e22acecb025146)),
closes [#550](https://code.castopod.org/adaures/castopod/issues/550)
## [1.13.3](https://code.castopod.org/adaures/castopod/compare/v1.13.2...v1.13.3) (2025-01-08)
### Bug Fixes
- remove exit function from podcast:import command to allow for
episodes:compute-downloads to run
([3359abf](https://code.castopod.org/adaures/castopod/commit/3359abf3fc7d6ddf9db2cacc3e25f7c6d99e33a6))
## [1.13.2](https://code.castopod.org/adaures/castopod/compare/v1.13.1...v1.13.2) (2024-12-28)
### Bug Fixes
- add downloads_count to episodes table, computed every hour
([5182d5d](https://code.castopod.org/adaures/castopod/commit/5182d5d67aa3c6f7906d4603efcec4b48f048991))
## [1.13.1](https://code.castopod.org/adaures/castopod/compare/v1.13.0...v1.13.1) (2024-12-05)
### Bug Fixes
- **api:** cast integers when creating episode
([7ca501d](https://code.castopod.org/adaures/castopod/commit/7ca501dd6f426a1d50ffb1ea759f1e2cc91c1d13))
# [1.13.0](https://code.castopod.org/adaures/castopod/compare/v1.12.11...v1.13.0) (2024-10-25)
## [2.0.0-next.3](https://code.castopod.org/adaures/castopod/compare/v2.0.0-next.2...v2.0.0-next.3) (2024-12-30)
### Features
- **api:** add Episode create and publish endpoints
([75cf78e](https://code.castopod.org/adaures/castopod/commit/75cf78e972c52528dc38be050dcb1eb1f8e626fa))
([a90cdfd](https://code.castopod.org/adaures/castopod/commit/a90cdfdcdbde7a8fb520c6815d7b757947aea055))
- **image:** add image size's width and height
([f50098e](https://code.castopod.org/adaures/castopod/commit/f50098ec8926c8ae40718f5f128b6de7fe721b46))
- **plugins:** add defaultValue for all field types
([d3a98db](https://code.castopod.org/adaures/castopod/commit/d3a98db6d0112b5f59daddd2708c09dd2e595332))
- **plugins:** add group field type + multiple option to render field arrays
([11ccd0e](https://code.castopod.org/adaures/castopod/commit/11ccd0ebe71d476d8c0dbfe28edcf01f7f362b83))
- **plugins:** add html field type + CodeEditor component + rework html head
generation
([8cf9c6d](https://code.castopod.org/adaures/castopod/commit/8cf9c6dc833aedcccbc4cdb309b111f84d97d629))
- **rss:** add option for 301 redirect to new feed url
([3a7d26f](https://code.castopod.org/adaures/castopod/commit/3a7d26fdf9bfeffb9247f8efe06d9040ae2fe5ff))
## [1.12.11](https://code.castopod.org/adaures/castopod/compare/v1.12.10...v1.12.11) (2024-10-16)
([8402cc2](https://code.castopod.org/adaures/castopod/commit/8402cc29d2d0c61b014a7e03e5ccce7d3c11782a))
### Bug Fixes
- add downloads_count to episodes table, computed every hour
([f981937](https://code.castopod.org/adaures/castopod/commit/f9819376455c371eb5bd3c84ad938698335a3d67))
- allow passing json to app.proxyIPs config to set it
([7d1460b](https://code.castopod.org/adaures/castopod/commit/7d1460b8e08beb447389c604995efd931c84fd72))
## [1.12.10](https://code.castopod.org/adaures/castopod/compare/v1.12.9...v1.12.10) (2024-10-03)
### Bug Fixes
- set user as www-data when running cron jobs in docker's supervisord config
([be3b6db](https://code.castopod.org/adaures/castopod/commit/be3b6db207204e14c9ad5d4d84384b15e0dbfa84))
## [1.12.9](https://code.castopod.org/adaures/castopod/compare/v1.12.8...v1.12.9) (2024-08-16)
### Bug Fixes
- **fediverse:** add "processing" and "failed" statuses to better manage
broadcast load
([cf9e072](https://code.castopod.org/adaures/castopod/commit/cf9e0724fcdb8d0194676880cc3b088b221f5a38)),
closes [#511](https://code.castopod.org/adaures/castopod/issues/511)
## [1.12.8](https://code.castopod.org/adaures/castopod/compare/v1.12.7...v1.12.8) (2024-08-16)
### Bug Fixes
- **podcast-model:** always query podcast from database when clearing cache
([995ca5b](https://code.castopod.org/adaures/castopod/commit/995ca5b197f8f917102a108dd07d1f81e99cc8e6))
## [1.12.7](https://code.castopod.org/adaures/castopod/compare/v1.12.6...v1.12.7) (2024-08-14)
### Bug Fixes
- **episode:** do not change slug when editing episode title
([89bf73b](https://code.castopod.org/adaures/castopod/commit/89bf73b869c28c2fcffa3dcbc3660fac3b6bf988)),
closes [#513](https://code.castopod.org/adaures/castopod/issues/513)
- **preview:** delete episode preview cache after editing episode
([6a2cdd0](https://code.castopod.org/adaures/castopod/commit/6a2cdd066ee13efc6489901bbdcbcc5fea35cd71)),
closes [#514](https://code.castopod.org/adaures/castopod/issues/514)
## [1.12.6](https://code.castopod.org/adaures/castopod/compare/v1.12.5...v1.12.6) (2024-08-09)
### Bug Fixes
([cbf739e](https://code.castopod.org/adaures/castopod/commit/cbf739e95cc0ad6e83a21353b8f4678e68d74f63))
- **api:** cast integers when creating episode
([775b302](https://code.castopod.org/adaures/castopod/commit/775b302f7c886e30e133c8a8c68764301b6c663b))
- **docker-image:** clear cache to account for new assets and data structure
changes
([e41245d](https://code.castopod.org/adaures/castopod/commit/e41245d2e758bce2a404749398bef89998638561)),
([63c763f](https://code.castopod.org/adaures/castopod/commit/63c763f941195b3758c4b91acd8c350a5e7bb9c2)),
closes [#510](https://code.castopod.org/adaures/castopod/issues/510)
## [1.12.5](https://code.castopod.org/adaures/castopod/compare/v1.12.4...v1.12.5) (2024-07-30)
### Bug Fixes
- edit remap functions to get episode in episode admin controllers
([9f74cca](https://code.castopod.org/adaures/castopod/commit/9f74cca342fedd896977efd2e89d0143959f3c4f))
- **episode:** do not change slug when editing episode title
([a83afb0](https://code.castopod.org/adaures/castopod/commit/a83afb0004511db80337806577fbc36f8d777116)),
closes [#513](https://code.castopod.org/adaures/castopod/issues/513)
- **fediverse:** add "processing" and "failed" statuses to better manage
broadcast load
([1d7583d](https://code.castopod.org/adaures/castopod/commit/1d7583d738219574ae3d45d294dc94e7e406472b)),
closes [#511](https://code.castopod.org/adaures/castopod/issues/511)
- **icons:** set correct names for lock and lock-unlock icons in premium banner
([37ee6d3](https://code.castopod.org/adaures/castopod/commit/37ee6d35b4bb66ce23dc271fb846200d1be0e7f6))
- **plugins:** clear cache after activating or deactivating plugin
([08c7df2](https://code.castopod.org/adaures/castopod/commit/08c7df2a5d5be340490c78deeef823167eb1b2fc))
- **plugins:** delete relevant cache when submitting settings
([00bd4c0](https://code.castopod.org/adaures/castopod/commit/00bd4c02ee23b181d74e7731626bfec3b1ff4916))
- **podcast-model:** always query podcast from database when clearing cache
([d30c49c](https://code.castopod.org/adaures/castopod/commit/d30c49cdff380c15db4f1851631a255a5baffcbe))
- **premium-podcasts:** update query to validate subscription
([2b1bbf3](https://code.castopod.org/adaures/castopod/commit/2b1bbf34303ead927f433b5c7d5d888ca3799954))
- **preview:** delete episode preview cache after editing episode
([732d429](https://code.castopod.org/adaures/castopod/commit/732d42923d0d7a66ff1ebd5841458e4205060560)),
closes [#514](https://code.castopod.org/adaures/castopod/issues/514)
- **release:** add conventional-changelog-conventionalcommits for CHANGELOG
generation
([6934c8a](https://code.castopod.org/adaures/castopod/commit/6934c8aa8f0b7f9eea7c3f6f4089c56b2391d9a6))
- **rss:** add subscription id to cache name to prevent premium feeds from
overlapping
([5310d86](https://code.castopod.org/adaures/castopod/commit/5310d8648af6d43b9090f8d9f8066f7b3a8f0aa7))
([74f9325](https://code.castopod.org/adaures/castopod/commit/74f9325946d03a0d4efce57045e41cc9454ff97c))
- set user as www-data when running cron jobs in docker's supervisord config
([65d74f1](https://code.castopod.org/adaures/castopod/commit/65d74f14e612be3757c9304518eee112705f5ff9))
- typo in EpisodeController remap function to get episode
([f288a75](https://code.castopod.org/adaures/castopod/commit/f288a750f580ab19b04a170cc76bf8769084e19d))
- update select and multi-select options to value/label arrays
([63f93f5](https://code.castopod.org/adaures/castopod/commit/63f93f585bec4a11022cc8c75deb34968cba2348))
## [1.12.4](https://code.castopod.org/adaures/castopod/compare/v1.12.3...v1.12.4) (2024-07-30)
### Internal
### Bug Fixes
- **plugins:** create Field objects per field type in settings forms + handle
rendering in class
([34be5bc](https://code.castopod.org/adaures/castopod/commit/34be5bccabb7531afdcc6ebaf1dd39e4dfbe0677))
- remove fields from podcast and episode entities to be replaced with plugins
([b869acb](https://code.castopod.org/adaures/castopod/commit/b869acb3a988a3616d883a41c25d9c8409bd5518))
- rename controller methods for views and actions to be more consistent
([85704bf](https://code.castopod.org/adaures/castopod/commit/85704bfbe03fe5e38ff5e76a0e1cf0e5f1275f57))
- update CodeIgniter to v4.5.6
([f295e9a](https://code.castopod.org/adaures/castopod/commit/f295e9aa4ca3129df24a22779f7c19bba7fac370))
- update codigniter-icons to v1.0.1
([fa6967e](https://code.castopod.org/adaures/castopod/commit/fa6967e65cef1705b19cbb205132c4c751507d53))
- update js dependencies to latest
([70c9797](https://code.castopod.org/adaures/castopod/commit/70c97971fcf5bbeee826578057ae0e3afbbbd8a8))
- **icons:** set correct names for lock and lock-unlock icons in premium banner
([94deaab](https://code.castopod.org/adaures/castopod/commit/94deaab3cd0912ff1a585bee174a096a84c68384))
- **premium-podcasts:** update query to validate subscription
([0e6d294](https://code.castopod.org/adaures/castopod/commit/0e6d2945f215453abbe7d9f90afd012d2507846b))
## [1.12.3](https://code.castopod.org/adaures/castopod/compare/v1.12.2...v1.12.3) (2024-07-04)
# [2.0.0-next.2](https://code.castopod.org/adaures/castopod/compare/v2.0.0-next.1...v2.0.0-next.2) (2024-07-08)
### Bug Fixes
- **audio-player:** set player icons to default instead of missing Castopod's
([c89d298](https://code.castopod.org/adaures/castopod/commit/c89d29867e122fe7d4d5563f0ab1e9993e2ece16))
## [1.12.2](https://code.castopod.org/adaures/castopod/compare/v1.12.1...v1.12.2) (2024-07-03)
### Bug Fixes
([0ba0a25](https://code.castopod.org/adaures/castopod/commit/0ba0a25b11bd67aeeb47a8179b72152dfd4a36da))
- broken icon call in frontend default pages template
([d8d2eb9](https://code.castopod.org/adaures/castopod/commit/d8d2eb92b741ecfc956b416db481f8c2dee84864))
## [1.12.1](https://code.castopod.org/adaures/castopod/compare/v1.12.0...v1.12.1) (2024-07-01)
### Bug Fixes
([3228362](https://code.castopod.org/adaures/castopod/commit/322836254e86be7878e21438177ee8f73f03a2fa))
- **manifest:** set repository url as required in docstring typings
([a8c81b3](https://code.castopod.org/adaures/castopod/commit/a8c81b3fa19a28dbd608027c231dcac31eafb38f))
- set correct icons parameters in map and funding links views
([b129813](https://code.castopod.org/adaures/castopod/commit/b129813ea5d38436563639b51ec9ed2882644228)),
([5d35524](https://code.castopod.org/adaures/castopod/commit/5d355248753be24e3cf324144ff076f2fc23be88)),
closes [#500](https://code.castopod.org/adaures/castopod/issues/500)
# [1.12.0](https://code.castopod.org/adaures/castopod/compare/v1.11.0...v1.12.0) (6/28/2024)
### Features
- **plugins:** add `minCastopodVersion` to denote incompatibility with previous
Castopod versions
([fc9ea75](https://code.castopod.org/adaures/castopod/commit/fc9ea7597e454e5c7c7af043d29af7bbe119e342))
- **plugins:** load and display LICENSE.md file if found in plugin's directory
([fee7905](https://code.castopod.org/adaures/castopod/commit/fee7905935a9adf963b4485b437fe4d972c14b5f))
# [2.0.0-next.1](https://code.castopod.org/adaures/castopod/compare/v1.11.0...v2.0.0-next.1) (6/19/2024)
### Bug Fixes
@ -248,14 +121,65 @@
([fc4f982](https://code.castopod.org/adaures/castopod/commit/fc4f9825568cd4384c5b3cfe972accd146548807)),
closes [#473](https://code.castopod.org/adaures/castopod/issues/473)
### Build System
- release next major version as prerelease
([8275226](https://code.castopod.org/adaures/castopod/commit/827522643e9f8a5ea9be05b4847dc637f0f43a13))
### Features
- add Plugins module with base files for plugins architecture
([7253e13](https://code.castopod.org/adaures/castopod/commit/7253e13ac2118f6f165f54ea0cbcd63d51ab9205))
- **plugins:** abstract settings form for general, podcast and episode types
([b62b483](https://code.castopod.org/adaures/castopod/commit/b62b483ad9ff114a22a9ee52e1a1a2c9fa444d42))
- **plugins:** activate / deactivate plugin using settings table
([27d2a1b](https://code.castopod.org/adaures/castopod/commit/27d2a1b0ffba9454dd54cbb4251a2d179b09762a))
- **plugins:** add aside with plugin metadata next to plugin's readme
([dfb7888](https://code.castopod.org/adaures/castopod/commit/dfb7888aeb689b4066abc37084e08cd7f1d0f15d))
- **plugins:** add before channel/item hooks to allow podcast/episode data edit
when generating rss
([80d2c48](https://code.castopod.org/adaures/castopod/commit/80d2c48ee265cb32ed0d710c488292fcbc120044))
- **plugins:** add json schema definition for plugin manifest
([b5eddf3](https://code.castopod.org/adaures/castopod/commit/b5eddf351f6f6fa1c299fbac31cbd056ef232330))
- **plugins:** add methods to easily retrieve general, podcast and episode
settings in hooks methods
([3a900bb](https://code.castopod.org/adaures/castopod/commit/3a900bbab68b819cedf8943540d2ee0aeb6e8539))
- **plugins:** add new field types + validate & cast user data before storing
settings
([6f833fc](https://code.castopod.org/adaures/castopod/commit/6f833fc76a3aa6c6b87c27ad18a2fb90e537e21e))
- **plugins:** add options to manifest for building forms and storing plugin
settings
([3d8aedf](https://code.castopod.org/adaures/castopod/commit/3d8aedf9c34e6927b6d3b11445d5f0e669b347d7))
- **plugins:** add settings page for podcast and episode if defined in the
plugin's manifest
([89ac92f](https://code.castopod.org/adaures/castopod/commit/89ac92fb412a04231ce52fd6480c9ab893b19ef5))
- **plugins:** add siteHead hook to add custom meta tags to public pages
([e80a33b](https://code.castopod.org/adaures/castopod/commit/e80a33bf2ad4fe1b47037add7470a6c2770f4036))
- **plugins:** display errors when plugin is invalid instead of crashing
([8ec7909](https://code.castopod.org/adaures/castopod/commit/8ec79097bbdbcbce622518ef61c068f20e0ef74e))
- **plugins:** handle empty states and long strings in UI
([45ac2a4](https://code.castopod.org/adaures/castopod/commit/45ac2a4be96532b9456e6af1d26ba4ada3649303))
- **plugins:** load and validate plugin manifest.json
([1510e36](https://code.castopod.org/adaures/castopod/commit/1510e36c0acd2b254622ec230acd1d2461ee9bf3))
- **plugins:** load plugins using file locator service
([587938d](https://code.castopod.org/adaures/castopod/commit/587938d2bf307b823af143586b9ec9e9b44e8dc1))
- **plugins:** load README.md file to view plugin's instructions in UI
([e6bfdfc](https://code.castopod.org/adaures/castopod/commit/e6bfdfc3902705285701c13c8067fe0f538425c6))
- **plugins:** register plugins using Plugin.php file instead of namespace +
simplify i18n structure
([2035c39](https://code.castopod.org/adaures/castopod/commit/2035c39fd138a1fd408516bd1972ab6a02544c10))
- **plugins:** uninstall plugins via CLI and admin UI
([9a80de4](https://code.castopod.org/adaures/castopod/commit/9a80de40686bbf4288da21cc2a6dde8036580e47))
- set owner email to hidden by default in podcast create form
([7a6d9df](https://code.castopod.org/adaures/castopod/commit/7a6d9df6db8a6184b8250ced0475f3e741dde7f4))
- support podcast:txt tag with verify use case
([57e459e](https://code.castopod.org/adaures/castopod/commit/57e459e187ed048430f4137172e22396cd02bf81)),
closes [#468](https://code.castopod.org/adaures/castopod/issues/468)
### BREAKING CHANGES
- next major release including plugins architecture
# [1.11.0](https://code.castopod.org/adaures/castopod/compare/v1.10.5...v1.11.0) (4/17/2024)
### Bug Fixes

View file

@ -1,128 +1,162 @@
# Contributor Covenant Code of Conduct
# Contributor Covenant 3.0 Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
We pledge to make our community welcoming, safe, and equitable for all.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
We are committed to fostering an environment that respects and promotes the
dignity, rights, and contributions of all individuals, regardless of
characteristics including race, ethnicity, caste, color, age, physical
characteristics, neurodiversity, disability, sex or gender, gender identity or
expression, sexual orientation, language, philosophy or religion, national or
social origin, socio-economic position, level of education, or other status. The
same privileges of participation are extended to everyone who participates in
good faith and in accordance with this Covenant.
## Our Standards
## Encouraged Behaviors
Examples of behavior that contributes to a positive environment for our
community include:
While acknowledging differences in social norms, we all strive to meet our
community's expectations for positive behavior. We also understand that our
words and actions may be interpreted differently than we intend based on
culture, background, or native language.
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall
community
With these considerations in mind, we agree to behave mindfully toward each
other and act in ways that center our shared values, including:
Examples of unacceptable behavior include:
1. Respecting the **purpose of our community**, our activities, and our ways of
gathering.
2. Engaging **kindly and honestly** with others.
3. Respecting **different viewpoints** and experiences.
4. **Taking responsibility** for our actions and contributions.
5. Gracefully giving and accepting **constructive feedback**.
6. Committing to **repairing harm** when it occurs.
7. Behaving in other ways that promote and sustain the **well-being of our
community**.
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Restricted Behaviors
## Enforcement Responsibilities
We agree to restrict the following behaviors in our community. Instances,
threats, and promotion of these behaviors are violations of this Code of
Conduct.
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
1. **Harassment.** Violating explicitly expressed boundaries or engaging in
unnecessary personal attention after any clear request to stop.
2. **Character attacks.** Making insulting, demeaning, or pejorative comments
directed at a community member or group of people.
3. **Stereotyping or discrimination.** Characterizing anyones personality or
behavior on the basis of immutable identities or traits.
4. **Sexualization.** Behaving in a way that would generally be considered
inappropriately intimate in the context or purpose of the community.
5. **Violating confidentiality**. Sharing or acting on someone's personal or
private information without their permission.
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm
toward any person or group.
7. Behaving in other ways that **threaten the well-being** of our community.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
### Other Restrictions
1. **Misleading identity.** Impersonating someone else for any reason, or
pretending to be someone else to evade enforcement actions.
2. **Failing to credit sources.** Not properly crediting the sources of content
you contribute.
3. **Promotional materials**. Sharing marketing or other commercial content in a
way that is outside the norms of the community.
4. **Irresponsible communication.** Failing to responsibly present content which
includes, links or describes any other restricted behaviors.
## Reporting an Issue
Tensions can occur between community members even when they are trying their
best to collaborate. Not every conflict represents a code of conduct violation,
and this Code of Conduct reinforces encouraged behaviors and norms that can help
avoid conflicts and minimize harm.
When an incident does occur, it is important to report it promptly. To report a
possible violation, email us at [abuse@castopod.org](mailto:abuse@castopod.org).
Community Moderators take reports of violations seriously and will make every
effort to respond in a timely manner. They will investigate all reports of code
of conduct violations, reviewing messages, logs, and recordings, or interviewing
witnesses and other participants. Community Moderators will keep investigation
and enforcement actions as transparent as possible while prioritizing safety and
confidentiality. In order to honor these values, enforcement actions are carried
out in private with the involved parties, but communicating to the whole
community may be part of a mutually agreed upon resolution.
## Addressing and Repairing Harm
If an investigation by the Community Moderators finds that this Code of Conduct
has been violated, the following enforcement ladder may be used to determine how
best to repair harm, based on the incident's impact on the individuals involved
and the community as a whole. Depending on the severity of a violation, lower
rungs on the ladder may be skipped.
1. Warning
1. Event: A violation involving a single incident or series of incidents.
2. Consequence: A private, written warning from the Community Moderators.
3. Repair: Examples of repair include a private written apology,
acknowledgement of responsibility, and seeking clarification on
expectations.
2. Temporarily Limited Activities
1. Event: A repeated incidence of a violation that previously resulted in a
warning, or the first incidence of a more serious violation.
2. Consequence: A private, written warning with a time-limited cooldown
period designed to underscore the seriousness of the situation and give
the community members involved time to process the incident. The cooldown
period may be limited to particular communication channels or interactions
with particular community members.
3. Repair: Examples of repair may include making an apology, using the
cooldown period to reflect on actions and impact, and being thoughtful
about re-entering community spaces after the period is over.
3. Temporary Suspension
1. Event: A pattern of repeated violation which the Community Moderators have
tried to address with warnings, or a single serious violation.
2. Consequence: A private written warning with conditions for return from
suspension. In general, temporary suspensions give the person being
suspended time to reflect upon their behavior and possible corrective
actions.
3. Repair: Examples of repair include respecting the spirit of the
suspension, meeting the specified conditions for return, and being
thoughtful about how to reintegrate with the community when the suspension
is lifted.
4. Permanent Ban
1. Event: A pattern of repeated code of conduct violations that other steps
on the ladder have failed to resolve, or a violation so serious that the
Community Moderators determine there is no way to keep the community safe
with this person as a member.
2. Consequence: Access to all community spaces, tools, and communication
channels is removed. In general, permanent bans should be rarely used,
should have strong reasoning behind them, and should only be resorted to
if working through other remedies has failed to change the behavior.
3. Repair: There is no possible repair in cases of this severity.
This enforcement ladder is intended as a guideline. It does not limit the
ability of Community Managers to use their discretion and judgment, in keeping
with the best interests of our community.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
an individual is officially representing the community in public or other
spaces. Examples of representing our community include using an official email
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[abuse@castopod.org](mailto:abuse@castopod.org). All complaints will be reviewed
and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
This Code of Conduct is adapted from the Contributor Covenant, version 3.0,
permanently available at
[https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
Contributor Covenant is stewarded by the Organization for Ethical Source and
licensed under CC BY-SA 4.0. To view a copy of this license, visit
[https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
For answers to common questions about Contributor Covenant, see the FAQ at
[https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq).
Translations are provided at
[https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations).
Additional enforcement and community guideline resources can be found at
[https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources).
The enforcement ladder was inspired by the work of
[Mozillas code of conduct team](https://github.com/mozilla/inclusion).

View file

@ -5,7 +5,7 @@
Castopod is a web app based on the `php` framework
[CodeIgniter 4](https://codeigniter.com).
We use [Docker](https://www.docker.com/) quickly setup a dev environment. A
We use [Docker](https://www.docker.com/) to quickly setup a dev environment. A
`docker-compose.yml` and `Dockerfile` are included in the project's root folder
to help you kickstart your contribution.
@ -16,9 +16,9 @@ to help you kickstart your contribution.
### 1. Pre-requisites
0. Install [docker](https://docs.docker.com/get-docker).
0. Install [Docker](https://docs.docker.com/get-docker).
1. Clone Castopod project by running:
1. Clone the Castopod repository by running:
```bash
git clone https://code.castopod.org/adaures/castopod.git
@ -79,7 +79,7 @@ to help you kickstart your contribution.
> [CodeIgniter4 User Guide](https://codeigniter.com/user_guide/index.html)
> for more info.
3. (for docker desktop) Add the repository you've cloned to docker desktop's
3. (for Docker desktop) Add the repository you've cloned to Docker desktop's
`Settings` > `Resources` > `File Sharing`
### 2. (recommended) Develop inside the app container with VSCode
@ -96,7 +96,7 @@ required services will be loaded automagically! 🪄
> The VSCode window will reload inside the dev container. Expect several
> minutes during first load as it is building all necessary services.
**Note**: The dev container will start by running Castopod's php server.
**Note**: The dev container will start by running Castopod's PHP server.
During development, you will have to start [Vite](https://vitejs.dev)'s dev
server for compiling the typescript code and styles:
@ -105,7 +105,7 @@ required services will be loaded automagically! 🪄
pnpm run dev
```
If there is any issue with the php server not running, you can restart them
If there is any issue with the PHP server not running, you can restart them
using the following commands:
```bash
@ -157,9 +157,9 @@ To see your changes, go to:
You do not wish to use the VSCode devcontainer? No problem!
1. Start docker containers manually:
1. Start the Docker containers manually:
Go to project's root folder and run:
Go to the project's root folder and run:
```bash
# starts all services declared in docker-compose.yml file
@ -223,14 +223,14 @@ You do not wish to use the VSCode devcontainer? No problem!
> For more info, check out the
> [Composer documentation](https://getcomposer.org/doc/).
2. Install javascript dependencies with [pnpm](https://pnpm.io/)
2. Install JavaScript dependencies with [pnpm](https://pnpm.io/)
```bash
pnpm install
```
> [!NOTE]
> The javascript dependencies aren't included in the repository. Pnpm will
> The JavaScript dependencies aren't included in the repository. Pnpm will
> check the `package.json` and `pnpm-lock.yaml` files to download the
> packages with the right versions. The dependencies will live under the
> `node_module` folder. For more info, check out the
@ -249,7 +249,7 @@ You do not wish to use the VSCode devcontainer? No problem!
> [!NOTE]
> The static assets generated live under the `public/assets` folder, it
> includes javascript, styles, images, fonts, icons and svg files.
> includes JavaScript, styles, images, fonts, icons and svg files.
### Initialize and populate database
@ -291,7 +291,7 @@ You do not wish to use the VSCode devcontainer? No problem!
php spark db:seed DevSuperadminSeeder
```
3. (optionnal) Populate the database with test data:
3. (optional) Populate the database with test data:
- Populate with fake podcast analytics:
```bash
@ -312,13 +312,13 @@ You do not wish to use the VSCode devcontainer? No problem!
docker-compose logs --tail 50 --follow --timestamps app
```
- Interact with redis server using included redis-cli command:
- Interact with the Redis server using included redis-cli command:
```bash
docker exec -it castopod_redis redis-cli
```
- Monitor the redis container:
- Monitor the Redis container:
```bash
docker-compose logs --tail 50 --follow --timestamps redis
@ -354,10 +354,37 @@ docker-compose down
docker-compose build app
```
Check [docker](https://docs.docker.com/engine/reference/commandline/docker/) and
Check [Docker](https://docs.docker.com/engine/reference/commandline/docker/) and
[docker-compose](https://docs.docker.com/compose/reference/) documentations for
more insights.
### Updating Documentation
Castopod's documentation is written in Markdown and uses the Astro Starlight
framework. To update Castopod's documentation, including the Getting Started
guide and User Guide:
1. Change directories to the `docs` directory and install the dependencies:
```bash
cd docs/
pnpm i
```
2. Start the documentation development server:
```bash
pnpm run dev --host
```
3. The documentation development server runs on port 4321. In your browser visit
`http://localhost:4321/docs`. If the page displays a 404 Not Found error,
click on the Castopod logo in the upper left hand corner of the page and the
documentation should load.
4. Edit the Markdown files with your documentation updates. The Astro Starlight
development server will automatically update each time you save a change.
## Known issues
### Allocation failed - JavaScript heap out of memory
@ -396,7 +423,7 @@ You may use Linux user namespaces to fix this on your machine:
username:100000:65536
```
3. Restart docker:
3. Restart Docker:
```bash
sudo systemctl restart docker

View file

@ -98,8 +98,8 @@ accurate comments, etc.) and any other requirements (such as test coverage).
Adhering to the following process is the best way to get your work included in
the project:
1. [Fork](https://docs.gitlab.com/ee/gitlab-basics/fork-project.html) the
project, clone your fork, and configure the remotes:
1. [Fork](https://docs.gitlab.com/ee/user/project/repository/forking_workflow.html)
the project, clone your fork, and configure the remotes:
```bash
# Clone your fork of the repo into the current directory

View file

@ -18,9 +18,9 @@ Javascript dependencies can be found in the [package.json](./package.json) file.
([Open Font License](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL))
- [RemixIcon](https://remixicon.com/)
([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License))
- [OPAWG/User agent list](https://github.com/opawg/user-agents)
- [OPAWG/User agent list](https://github.com/opawg/user-agents-v2)
([by Open Podcast Analytics Working Group](https://github.com/opawg))
([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE))
([MIT license](https://github.com/opawg/user-agents-v2/blob/master/LICENSE))
- [OPAWG/podcast-rss-useragents](https://github.com/opawg/podcast-rss-useragents)
([by Open Podcast Analytics Working Group](https://github.com/opawg))
([MIT license](https://github.com/opawg/podcast-rss-useragents/blob/master/LICENSE))

View file

@ -1,7 +1,7 @@
<div align="center">
<h1>
<a href="https://castopod.org/">
<img src="https://docs.castopod.org/images/castopod-logo-inline.svg" alt="Castopod" height="64px" />
<img src="./docs/src/assets/castopod-logo-inline.svg" alt="Castopod" height="64px" />
</a>
</h1>
</div>
@ -46,18 +46,17 @@ Thanks goes to these wonderful people
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table class="all-contributors-table">
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yassinedoghri"><img src="https://code.castopod.org/uploads/-/system/user/avatar/3/avatar.png?s=100" width="100px;" alt="Yassine Doghri"/><br /><sub><b>Yassine Doghri</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a> <a href="https://code.castopod.org/adaures/castopod/issues?author_username=yassinedoghri" title="Bug reports">🐛</a> <a href="https://code.castopod.org/adaures/castopod/commits/master" title="Documentation">📖</a> <a href="https://code.castopod.org/adaures/castopod/merge_requests?scope=all&state=all&approver_usernames[]=yassinedoghri" title="Reviewed Pull Requests">👀</a> <a href="#maintenance-yassinedoghri" title="Maintenance">🚧</a> <a href="#content-yassinedoghri" title="Content">🖋</a> <a href="#design-yassinedoghri" title="Design">🎨</a> <a href="#a11y-yassinedoghri" title="Accessibility">️️️️♿️</a> <a href="https://translate.castopod.org" title="Translation">🌍</a> <a href="#question-yassinedoghri" title="Answering Questions">💬</a> <a href="#mentoring-yassinedoghri" title="Mentoring">🧑‍🏫</a> <a href="#infra-yassinedoghri" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-yassinedoghri" title="Ideas, Planning, & Feedback">🤔</a> <a href="#projectManagement-yassinedoghri" title="Project Management">📆</a> <a href="https://blog.castopod.org/author/yassinedoghri/" title="Blogposts">📝</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://yassinedoghri.com"><img src="https://avatars.githubusercontent.com/u/11021441?v=4?s=100" width="100px;" alt="Yassine Doghri"/><br /><sub><b>Yassine Doghri</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a> <a href="https://code.castopod.org/adaures/castopod/issues?author_username=yassinedoghri" title="Bug reports">🐛</a> <a href="https://code.castopod.org/adaures/castopod/commits/master" title="Documentation">📖</a> <a href="https://code.castopod.org/adaures/castopod/merge_requests?scope=all&state=all&approver_usernames[]=yassinedoghri" title="Reviewed Pull Requests">👀</a> <a href="#maintenance-yassinedoghri" title="Maintenance">🚧</a> <a href="#content-yassinedoghri" title="Content">🖋</a> <a href="#design-yassinedoghri" title="Design">🎨</a> <a href="#a11y-yassinedoghri" title="Accessibility">️️️️♿️</a> <a href="https://translate.castopod.org" title="Translation">🌍</a> <a href="#question-yassinedoghri" title="Answering Questions">💬</a> <a href="#mentoring-yassinedoghri" title="Mentoring">🧑‍🏫</a> <a href="#infra-yassinedoghri" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-yassinedoghri" title="Ideas, Planning, & Feedback">🤔</a> <a href="#projectManagement-yassinedoghri" title="Project Management">📆</a> <a href="https://blog.castopod.org/author/yassinedoghri/" title="Blogposts">📝</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/benjamin"><img src="https://code.castopod.org/uploads/-/system/user/avatar/2/avatar.png?s=100" width="100px;" alt="Benjamin Bellamy"/><br /><sub><b>Benjamin Bellamy</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a> <a href="https://code.castopod.org/adaures/castopod/issues?author_username=benjamin" title="Bug reports">🐛</a> <a href="https://code.castopod.org/adaures/castopod/merge_requests?scope=all&state=all&approver_usernames[]=benjamin" title="Reviewed Pull Requests">👀</a> <a href="#content-benjamin" title="Content">🖋</a> <a href="https://translate.castopod.org" title="Translation">🌍</a> <a href="#question-benjamin" title="Answering Questions">💬</a> <a href="#infra-benjamin" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-benjamin" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://blog.castopod.org/author/benjamin-bellamy/" title="Blogposts">📝</a> <a href="#projectManagement-benjamin" title="Project Management">📆</a> <a href="#talk-benjamin" title="Talks">📢</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ola-hn"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Ola Hneini"/><br /><sub><b>Ola Hneini</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a> <a href="https://code.castopod.org/adaures/castopod/merge_requests?scope=all&state=all&approver_usernames[]=ola" title="Reviewed Pull Requests">👀</a> <a href="https://code.castopod.org/adaures/castopod/commits/master" title="Documentation">📖</a> <a href="#maintenance-ola" title="Maintenance">🚧</a> <a href="#question-ola" title="Answering Questions">💬</a> <a href="#ideas-ola" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://mamot.fr/@rdelaage"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Romain de Laage"/><br /><sub><b>Romain de Laage</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a> <a href="#infra-rdelaage" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://code.castopod.org/adaures/castopod/commits/master" title="Documentation">📖</a> <a href="https://translate.castopod.org" title="Translation">🌍</a> <a href="#ideas-rdelaage" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://twitter.com/lyonelbernard"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Lyonel Bernard"/><br /><sub><b>Lyonel Bernard</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=Lyonel" title="Bug reports">🐛</a> <a href="#question-Lyonel" title="Answering Questions">💬</a> <a href="#audio-Lyonel" title="Audio">🔊</a> <a href="#ideas-Lyonel" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.crypticchameleon.com/"><img src="https://secure.gravatar.com/avatar/7c2a721b52d0763673a600e8f01bd745?s=80&d=identicon?s=100" width="100px;" alt="Christopher Lagonick-Weitzel"/><br /><sub><b>Christopher Lagonick-Weitzel</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=ctlw83" title="Bug reports">🐛</a> <a href="#question-ctlw83" title="Answering Questions">💬</a> <a href="#audio-ctlw83" title="Audio">🔊</a> <a href="#ideas-ctlw83" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://ernestoacosta.me/"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Ernesto Acosta"/><br /><sub><b>Ernesto Acosta</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=ernestoacostame" title="Bug reports">🐛</a> <a href="#audio-ernestoacostame" title="Audio">🔊</a> <a href="https://translate.castopod.org" title="Translation">🌍</a> <a href="#question-ernestoacostame" title="Answering Questions">💬</a> <a href="#ideas-ernestoacostame" title="Ideas, Planning, & Feedback">🤔</a></td>
</tr><br />
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://mastodon.fedi.bzh/@ewen"><img src="https://mastodon.fedi.bzh/system/accounts/avatars/000/000/002/original/6f387690a504ae46.jpg?s=100" width="100px;" alt="Ewen"/><br /><sub><b>Ewen</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a> <a href="#ideas-3wen" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/Behel"><img src="https://secure.gravatar.com/avatar/ad63ee8ef8e3db8253d21e5012d2724f?s=80&d=identicon?s=100" width="100px;" alt="Bastien Luneteau"/><br /><sub><b>Bastien Luneteau</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a> <a href="https://code.castopod.org/adaures/castopod/issues?author_username=Behel" title="Bug reports">🐛</a></td>
@ -66,7 +65,7 @@ Thanks goes to these wonderful people
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/mspanc"><img src="https://secure.gravatar.com/avatar/eed8337939641eac5ad0b570bd6acf96?s=80&d=identicon?s=100" width="100px;" alt="Marcin Lewandowski"/><br /><sub><b>Marcin Lewandowski</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=mspanc" title="Bug reports">🐛</a> <a href="#ideas-mspanc" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/SJanik"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Sebastian Janik"/><br /><sub><b>Sebastian Janik</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/patryk"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Patryk Karczmarczyk"/><br /><sub><b>Patryk Karczmarczyk</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a></td>
</tr><br />
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/ddenis"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="denis d"/><br /><sub><b>denis d</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=ddenis" title="Bug reports">🐛</a> <a href="#ideas-ddenis" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/douglaskastle"><img src="https://secure.gravatar.com/avatar/b7e652ba4b6bcd440afa069e7f7bc9e6?s=80&d=identicon?s=100" width="100px;" alt="Douglas Kastle"/><br /><sub><b>Douglas Kastle</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=douglaskastle" title="Bug reports">🐛</a> <a href="#ideas-douglaskastle" title="Ideas, Planning, & Feedback">🤔</a></td>
@ -75,7 +74,7 @@ Thanks goes to these wonderful people
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/jonas"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Jonas S"/><br /><sub><b>Jonas S</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/yannL"><img src="https://secure.gravatar.com/avatar/9c46600ce566ec6d526370d8e104b1c8?s=80&d=identicon?s=100" width="100px;" alt="LEFEBVRE Yann"/><br /><sub><b>LEFEBVRE Yann</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=yannL" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/spaetz"><img src="https://secure.gravatar.com/avatar/278e1af65e82993efd0ba7bbbacf6435?s=80&d=identicon?s=100" width="100px;" alt="Sebastian Späth"/><br /><sub><b>Sebastian Späth</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=spaetz" title="Bug reports">🐛</a> <a href="#ideas-spaetz" title="Ideas, Planning, & Feedback">🤔</a></td>
</tr><br />
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/rocky"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="rocky III"/><br /><sub><b>rocky III</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=rocky" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/Regenpfeifer"><img src="https://code.castopod.org/uploads/-/system/user/avatar/103/avatar.png?s=100" width="100px;" alt="Hermann Josef Eckl"/><br /><sub><b>Hermann Josef Eckl</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=Regenpfeifer" title="Bug reports">🐛</a></td>
@ -84,7 +83,7 @@ Thanks goes to these wonderful people
<td align="center" valign="top" width="14.28%"><a href="https://achouvardas.eu/"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Angelos Chouvardas"/><br /><sub><b>Angelos Chouvardas</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://mastodon.fjerland.no/@eivind"><img src="https://mastodon.fjerland.no/system/accounts/avatars/107/769/768/295/192/222/original/e5c985fea6487dcb.jpg?s=100" width="100px;" alt="Eivind"/><br /><sub><b>Eivind</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/forght"><img src="https://crowdin-static.downloads.crowdin.com/avatar/15073833/large/82d1e2e443a6df7edc43a7405dfeeb75_default.png?s=100" width="100px;" alt="forght"/><br /><sub><b>forght</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
</tr><br />
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/glottis0q"><img src="https://crowdin-static.downloads.crowdin.com/avatar/15209934/large/8b17ef6a7399f0b82a8198f87c224195.png?s=100" width="100px;" alt="glottis0q"/><br /><sub><b>glottis0q</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://mstdn.fr/@ButterflyOfFire"><img src="https://static.mstdn.fr/static/accounts/avatars/000/065/901/original/5908e93ad5447f15.png?s=100" width="100px;" alt="ButterflyOfFire"/><br /><sub><b>ButterflyOfFire</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
@ -93,7 +92,7 @@ Thanks goes to these wonderful people
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/cthtc"><img src="https://crowdin-static.downloads.crowdin.com/avatar/15211502/large/ed0651060cb8474a9519b5168bd377c1_default.png?s=100" width="100px;" alt="CTHTC"/><br /><sub><b>CTHTC</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/retrograde"><img src="https://crowdin-static.downloads.crowdin.com/avatar/15021651/large/b10c4057f85bf4de49c7fdf01354ecde.jpeg?s=100" width="100px;" alt="Russian Retro"/><br /><sub><b>Russian Retro</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/mareklach"><img src="https://crowdin-static.downloads.crowdin.com/avatar/13572324/large/3eeba8d569c247ace33862bf4ef4748f.jpeg?s=100" width="100px;" alt="Marek L'ach"/><br /><sub><b>Marek L'ach</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
</tr><br />
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/gunchleoc"><img src="https://crowdin-static.downloads.crowdin.com/avatar/13043878/large/3223f7b606296a8b1c92c5de39c459a2_default.png?s=100" width="100px;" alt="GunChleoc"/><br /><sub><b>GunChleoc</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/gabisnow"><img src="https://crowdin-static.downloads.crowdin.com/avatar/15214858/large/5b083bdf9c9e9de67cc6ee72a6c8db18_default.png?s=100" width="100px;" alt="GabiSnow"/><br /><sub><b>GabiSnow</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
@ -102,25 +101,24 @@ Thanks goes to these wonderful people
<td align="center" valign="top" width="14.28%"><a href="https://dimitriregnier.net/"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Dimitri Regnier"/><br /><sub><b>Dimitri Regnier</b></sub></a><br /><a href="#ideas-dimregnier" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://im.irithys.com/@thy"><img src="https://crowdin-static.downloads.crowdin.com/avatar/15405614/large/3086461c47cce0a0c031925e5f943412.png?s=100" width="100px;" alt="irithys"/><br /><sub><b>irithys</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://twitter.com/caos30"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Sergi"/><br /><sub><b>Sergi</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
</tr><br />
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/xosem"><img src="https://crowdin-static.downloads.crowdin.com/avatar/12617257/large/a201650da44fed28890b0e0d8477a663.jpg?s=100" width="100px;" alt="ghose (XoseM)"/><br /><sub><b>ghose (XoseM)</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/basen1982"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="Andreas Olsson"/><br /><sub><b>Andreas Olsson</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/leonfrom"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="leonfrom"/><br /><sub><b>leonfrom</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/agentcobra57"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="agentcobra"/><br /><sub><b>agentcobra</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/alephoto85"><img src="https://crowdin-static.downloads.crowdin.com/avatar/15094649/large/530391f54157af52ae33058ec15b0f99.jpg?s=100" width="100px;" alt="Alessandro"/><br /><sub><b>Alessandro</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://crowdin.com/profile/liimee"><img src="https://castopod.org/assets/images/castopod-avatar.jpg?s=100" width="100px;" alt="liimee"/><br /><sub><b>liimee</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ahmedsabouni"><img src="https://avatars.githubusercontent.com/u/74497842?v=4?s=100" width="100px;" alt="Ahmed Sabouni"/><br /><sub><b>Ahmed Sabouni</b></sub></a><br /><a href="https://translate.castopod.org" title="Translation">🌍</a></td>
</tr><br />
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/KrzysztofDomanczyk"><img src="https://avatars.githubusercontent.com/u/75178474?v=4?s=100" width="100px;" alt="KrzysztofDomanczyk"/><br /><sub><b>KrzysztofDomanczyk</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://code.castopod.org/NeoluxConsulting"><img src="https://secure.gravatar.com/avatar/6e745565356330c1e29a85d52bffdaa1?s=80&d=identicon?s=100" width="100px;" alt="Guy Martin"/><br /><sub><b>Guy Martin</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=NeoluxConsulting" title="Bug reports">🐛</a> <a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Dwev"><img src="https://avatars.githubusercontent.com/u/46626050?v=4?s=100" width="100px;" alt="Guy Martin"/><br /><sub><b>Guy Martin</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/issues?author_username=Dwev" title="Bug reports">🐛</a> <a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/prcutler"><img src="https://avatars.githubusercontent.com/u/67276?v=4?s=100" width="100px;" alt="Paul Cutler"/><br /><sub><b>Paul Cutler</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Documentation">📖</a> <a href="#question-prcutler" title="Answering Questions">💬</a> <a href="#ideas-prcutler" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nateritter"><img src="https://avatars.githubusercontent.com/u/198798?v=4?s=100" width="100px;" alt="Nate Ritter"/><br /><sub><b>Nate Ritter</b></sub></a><br /><a href="https://code.castopod.org/adaures/castopod/commits/master" title="Code">💻</a></td>
</tr>
</tbody>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
@ -157,10 +155,10 @@ backers. If you'd like to help, please consider
<tbody>
<tr>
<td align="center">
<a href="https://docs.castopod.org/images/sponsors/adaures.svg" target="_blank" rel="noopener noreferrer"><img height="48" src="https://docs.castopod.org/images/sponsors/adaures.svg" alt="Netlify" /></a>
<a href="https://docs.castopod.org/images/sponsors/adaures.svg" target="_blank" rel="noopener noreferrer"><img height="48" src="./docs/src/assets/images/sponsors/adaures.svg" alt="Ad Aures" /></a>
</td>
<td align="center">
<a href="https://nlnet.nl/project/Castopod/" target="_blank" rel="noopener noreferrer"><img src="https://docs.castopod.org/images/sponsors/nlnet.svg" alt="NLnet Logo" height="48" /></a>
<a href="https://nlnet.nl/project/Castopod/" target="_blank" rel="noopener noreferrer"><img src="./docs/src/assets/images/sponsors/nlnet.svg" alt="NLnet Logo" height="48" /></a>
</td>
</tr>
</tbody>

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
use Override;
class App extends BaseConfig
{
@ -123,19 +124,17 @@ class App extends BaseConfig
* @var list<string>
*/
public array $supportedLocales = [
'en', // keep english language first in case locale negotiation does not work
'en',
'fr',
'br',
'ca',
'cs',
'de',
'es',
'lt',
'nn-no',
'pl',
'de',
'pt-br',
'sr-latn',
'nn-no',
'es',
'zh-hans',
'ca',
'br',
'sr-latn',
];
/**
@ -274,6 +273,7 @@ class App extends BaseConfig
*
* @param mixed $property
*/
#[Override]
protected function initEnvValue(&$property, string $name, string $prefix, string $shortPrefix): void
{
// if attempting to set property from ENV, first set to empty string

View file

@ -48,6 +48,7 @@ class Autoload extends AutoloadConfig
'Modules\Media' => ROOTPATH . 'modules/Media/',
'Modules\MediaClipper' => ROOTPATH . 'modules/MediaClipper/',
'Modules\Platforms' => ROOTPATH . 'modules/Platforms/',
'Modules\Plugins' => ROOTPATH . 'modules/Plugins/',
'Modules\PodcastImport' => ROOTPATH . 'modules/PodcastImport/',
'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/',
'Modules\Update' => ROOTPATH . 'modules/Update/',
@ -55,7 +56,6 @@ class Autoload extends AutoloadConfig
'Themes' => ROOTPATH . 'themes',
'ViewComponents' => APPPATH . 'Libraries/ViewComponents/',
'ViewThemes' => APPPATH . 'Libraries/ViewThemes/',
'Vite' => APPPATH . 'Libraries/Vite/',
];
/**
@ -93,7 +93,7 @@ class Autoload extends AutoloadConfig
*
* @var list<string>
*/
public $files = [APPPATH . 'Libraries/ViewComponents/Helpers/view_components_helper.php'];
public $files = [];
/**
* -------------------------------------------------------------------
@ -106,5 +106,5 @@ class Autoload extends AutoloadConfig
*
* @var list<string>
*/
public $helpers = ['auth', 'setting'];
public $helpers = ['auth', 'setting', 'plugins'];
}

View file

@ -8,6 +8,19 @@ use CodeIgniter\Config\BaseConfig;
class CURLRequest extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* CURLRequest Share Connection Options
* --------------------------------------------------------------------------
*
* Share connection options between requests.
*
* @var list<int>
*
* @see https://www.php.net/manual/en/curl.constants.php#constant.curl-lock-data-connect
*/
public array $shareConnectionOptions = [CURL_LOCK_DATA_CONNECT, CURL_LOCK_DATA_DNS];
/**
* --------------------------------------------------------------------------
* CURLRequest Share Options

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Config;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Cache\Handlers\ApcuHandler;
use CodeIgniter\Cache\Handlers\DummyHandler;
use CodeIgniter\Cache\Handlers\FileHandler;
use CodeIgniter\Cache\Handlers\MemcachedHandler;
@ -76,6 +77,7 @@ class Cache extends BaseConfig
* --------------------------------------------------------------------------
* File settings
* --------------------------------------------------------------------------
*
* Your file storage preferences can be specified below, if you are using
* the File driver.
*
@ -90,6 +92,7 @@ class Cache extends BaseConfig
* -------------------------------------------------------------------------
* Memcached settings
* -------------------------------------------------------------------------
*
* Your Memcached servers can be specified below, if you are using
* the Memcached drivers.
*
@ -111,14 +114,24 @@ class Cache extends BaseConfig
* Your Redis server can be specified below, if you are using
* the Redis or Predis drivers.
*
* @var array{host?: string, password?: string|null, port?: int, timeout?: int, database?: int}
* @var array{
* host?: string,
* password?: string|null,
* port?: int,
* timeout?: int,
* async?: bool,
* persistent?: bool,
* database?: int
* }
*/
public array $redis = [
'host' => '127.0.0.1',
'password' => null,
'port' => 6379,
'timeout' => 0,
'database' => 0,
'host' => '127.0.0.1',
'password' => null,
'port' => 6379,
'timeout' => 0,
'async' => false, // specific to Predis and ignored by the native Redis extension
'persistent' => false,
'database' => 0,
];
/**
@ -132,6 +145,7 @@ class Cache extends BaseConfig
* @var array<string, class-string<CacheInterface>>
*/
public array $validHandlers = [
'apcu' => ApcuHandler::class,
'dummy' => DummyHandler::class,
'file' => FileHandler::class,
'memcached' => MemcachedHandler::class,
@ -158,4 +172,28 @@ class Cache extends BaseConfig
* @var bool|list<string>
*/
public $cacheQueryString = false;
/**
* --------------------------------------------------------------------------
* Web Page Caching: Cache Status Codes
* --------------------------------------------------------------------------
*
* HTTP status codes that are allowed to be cached. Only responses with
* these status codes will be cached by the PageCache filter.
*
* Default: [] - Cache all status codes (backward compatible)
*
* Recommended: [200] - Only cache successful responses
*
* You can also use status codes like:
* [200, 404, 410] - Cache successful responses and specific error codes
* [200, 201, 202, 203, 204] - All 2xx successful responses
*
* WARNING: Using [] may cache temporary error pages (404, 500, etc).
* Consider restricting to [200] for production applications to avoid
* caching errors that should be temporary.
*
* @var list<int>
*/
public array $cacheStatusCodes = [];
}

View file

@ -11,7 +11,7 @@ declare(strict_types=1);
|
| NOTE: this constant is updated upon release with Continuous Integration.
*/
defined('CP_VERSION') || define('CP_VERSION', '1.15.5');
defined('CP_VERSION') || define('CP_VERSION', '2.0.0-next.3');
/*
| --------------------------------------------------------------------
@ -24,10 +24,23 @@ defined('CP_VERSION') || define('CP_VERSION', '1.15.5');
| classes should use.
|
| NOTE: changing this will require manually modifying the
| existing namespaces of App\* namespaced-classes.
| existing namespaces of App* namespaced-classes.
*/
defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
/*
| --------------------------------------------------------------------
| Plugins Path
| --------------------------------------------------------------------
|
| This defines the folder in which plugins will live.
*/
defined('PLUGINS_PATH') ||
define('PLUGINS_PATH', ROOTPATH . 'plugins' . DIRECTORY_SEPARATOR);
defined('PLUGINS_KEY_PATTERN') ||
define('PLUGINS_KEY_PATTERN', '[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9]([_.-]?[a-z0-9]+)*');
/*
| --------------------------------------------------------------------------
| Composer Path

View file

@ -26,14 +26,24 @@ class ContentSecurityPolicy extends BaseConfig
*/
public ?string $reportURI = null;
/**
* Specifies a reporting endpoint to which violation reports ought to be sent.
*/
public ?string $reportTo = null;
/**
* Instructs user agents to rewrite URL schemes, changing HTTP to HTTPS. This directive is for websites with large
* numbers of old URLs that need to be rewritten.
*/
public bool $upgradeInsecureRequests = false;
// -------------------------------------------------------------------------
// CSP DIRECTIVES SETTINGS
// NOTE: once you set a policy to 'none', it cannot be further restricted
// -------------------------------------------------------------------------
/**
* Will default to self if not overridden
* Will default to `'self'` if not overridden
*
* @var list<string>|string|null
*/
@ -46,6 +56,21 @@ class ContentSecurityPolicy extends BaseConfig
*/
public string | array $scriptSrc = 'self';
/**
* Specifies valid sources for JavaScript <script> elements.
*
* @var list<string>|string
*/
public array|string $scriptSrcElem = 'self';
/**
* Specifies valid sources for JavaScript inline event
* handlers and JavaScript URLs.
*
* @var list<string>|string
*/
public array|string $scriptSrcAttr = 'self';
/**
* Lists allowed stylesheets' URLs.
*
@ -53,6 +78,21 @@ class ContentSecurityPolicy extends BaseConfig
*/
public string | array $styleSrc = 'self';
/**
* Specifies valid sources for stylesheets <link> elements.
*
* @var list<string>|string
*/
public array|string $styleSrcElem = 'self';
/**
* Specifies valid sources for stylesheets inline
* style attributes and `<style>` elements.
*
* @var list<string>|string
*/
public array|string $styleSrcAttr = 'self';
/**
* Defines the origins from which images can be loaded.
*
@ -132,6 +172,11 @@ class ContentSecurityPolicy extends BaseConfig
*/
public string | array | null $manifestSrc = null;
/**
* @var list<string>|string
*/
public array|string $workerSrc = [];
/**
* Limits the kinds of plugins a page may invoke.
*
@ -147,17 +192,17 @@ class ContentSecurityPolicy extends BaseConfig
public string | array | null $sandbox = null;
/**
* Nonce tag for style
* Nonce placeholder for style tags.
*/
public string $styleNonceTag = '{csp-style-nonce}';
/**
* Nonce tag for script
* Nonce placeholder for script tags.
*/
public string $scriptNonceTag = '{csp-script-nonce}';
/**
* Replace nonce tag automatically
* Replace nonce tag automatically?
*/
public bool $autoNonce = true;
}

View file

@ -34,6 +34,11 @@ class Email extends BaseConfig
*/
public string $SMTPHost = '';
/**
* Which SMTP authentication method to use: login, plain
*/
public string $SMTPAuthMethod = 'login';
/**
* SMTP Username
*/

View file

@ -25,6 +25,23 @@ class Encryption extends BaseConfig
*/
public string $key = '';
/**
* --------------------------------------------------------------------------
* Previous Encryption Keys
* --------------------------------------------------------------------------
*
* When rotating encryption keys, add old keys here to maintain ability
* to decrypt data encrypted with previous keys. Encryption always uses
* the current $key. Decryption tries current key first, then falls back
* to previous keys if decryption fails.
*
* In .env file, use comma-separated string:
* encryption.previousKeys = hex2bin:9be8c64fcea509867...,hex2bin:3f5a1d8e9c2b7a4f6...
*
* @var list<string>|string
*/
public array|string $previousKeys = '';
/**
* --------------------------------------------------------------------------
* Encryption Driver to Use

View file

@ -56,7 +56,8 @@ Events::on('pre_system', static function (): void {
// Hot Reload route - for framework use on the hot reloader.
if (ENVIRONMENT === 'development') {
service('routes')->get('__hot-reload', static function (): void {
(new HotReloader())->run();
new HotReloader()
->run();
});
}
}

View file

@ -42,7 +42,7 @@ class Fediverse extends FediverseBaseConfig
}
['dirname' => $dirname, 'extension' => $extension, 'filename' => $filename] = pathinfo(
$defaultBanner['path']
$defaultBanner['path'],
);
$defaultBannerPath = $filename;
if ($dirname !== '.') {

View file

@ -63,4 +63,13 @@ class Format extends BaseConfig
'application/xml' => 0,
'text/xml' => 0,
];
/**
* --------------------------------------------------------------------------
* Maximum depth for JSON encoding.
* --------------------------------------------------------------------------
*
* This value determines how deep the JSON encoder will traverse nested structures.
*/
public int $jsonEncodeDepth = 512;
}

View file

@ -25,7 +25,7 @@ class Generators extends BaseConfig
*
* YOU HAVE BEEN WARNED!
*
* @var array<string, array<string, string>|string>
* @var array<string, string|array<string,string>>
*/
public array $views = [
'make:cell' => [

42
app/Config/Hostnames.php Normal file
View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Config;
class Hostnames
{
// List of known two-part TLDs for subdomain extraction
public const TWO_PART_TLDS = [
'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk',
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp',
'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz',
'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in',
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg',
'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za',
'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr',
'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th',
'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my',
'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx',
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br',
'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il',
'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id',
'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk',
'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw',
'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa',
'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae',
'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr',
'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke',
'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng',
'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk',
'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg',
'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy',
'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk',
'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd',
'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar',
'gob.cl', 'com.pl', 'net.pl', 'org.pl', 'gov.pl', 'edu.pl',
'co.ir', 'ac.ir', 'org.ir', 'id.ir', 'gov.ir', 'sch.ir', 'net.ir',
];
}

View file

@ -17,6 +17,8 @@ class Images extends BaseConfig
/**
* The path to the image library. Required for ImageMagick, GraphicsMagick, or NetPBM.
*
* @deprecated 4.7.0 No longer used.
*/
public string $libraryPath = '/usr/local/bin/convert';

View file

@ -46,4 +46,19 @@ class Migrations extends BaseConfig
* - Y_m_d_His_
*/
public string $timestampFormat = 'Y-m-d-His_';
/**
* --------------------------------------------------------------------------
* Enable/Disable Migration Lock
* --------------------------------------------------------------------------
*
* Locking is disabled by default.
*
* When enabled, it will prevent multiple migration processes
* from running at the same time by using a lock mechanism.
*
* This is useful in production environments to avoid conflicts
* or race conditions during concurrent deployments.
*/
public bool $lock = false;
}

View file

@ -279,13 +279,8 @@ class Mimes
'srt' => ['application/x-subrip', 'text/srt', 'text/plain', 'application/octet-stream'],
'vtt' => ['text/vtt', 'text/plain'],
'ico' => ['image/x-icon', 'image/x-ico', 'image/vnd.microsoft.icon'],
'stl' => [
'application/sla',
'application/vnd.ms-pki.stl',
'application/x-navistyle',
'model/stl',
'application/octet-stream',
],
'stl' => ['application/sla', 'application/vnd.ms-pki.stl', 'application/x-navistyle', 'model/stl',
'application/octet-stream', ],
];
/**
@ -312,7 +307,7 @@ class Mimes
* @param string|null $proposedExtension - default extension (in case there is more than one with the same mime type)
* @return string|null The extension determined, or null if unable to match.
*/
public static function guessExtensionFromType(string $type, string $proposedExtension = null): ?string
public static function guessExtensionFromType(string $type, ?string $proposedExtension = null): ?string
{
$type = trim(strtolower($type), '. ');

View file

@ -9,6 +9,8 @@ namespace Config;
*
* NOTE: This class does not extend BaseConfig for performance reasons.
* So you cannot replace the property values with Environment Variables.
*
* WARNING: Do not use these options when running the app in the Worker Mode.
*/
class Optimize
{

View file

@ -15,7 +15,6 @@ namespace Config;
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*/
class Paths
{
/**

View file

@ -4,6 +4,11 @@ declare(strict_types=1);
use CodeIgniter\Router\RouteCollection;
/**
* --------------------------------------------------------------------
* Placeholder definitions
* --------------------------------------------------------------------
*/
/** @var RouteCollection $routes */
$routes->addPlaceholder('podcastHandle', '[a-zA-Z0-9\_]{1,32}');
$routes->addPlaceholder('slug', '[a-zA-Z0-9\-]{1,128}');
@ -154,7 +159,7 @@ $routes->group('@(:podcastHandle)', static function ($routes): void {
$routes->get('comments/(:uuid)/replies', 'EpisodeCommentController::replies/$1/$2/$3', [
'as' => 'episode-comment-replies',
]);
$routes->post('comments/(:uuid)/like', 'EpisodeCommentController::attemptLike/$1/$2/$3', [
$routes->post('comments/(:uuid)/like', 'EpisodeCommentController::likeAction/$1/$2/$3', [
'as' => 'episode-comment-attempt-like',
]);
$routes->get('oembed.json', 'EpisodeController::oembedJSON/$1/$2', [
@ -222,9 +227,9 @@ $routes->get('/pages/(:slug)', 'PageController::index/$1', [
* Overwriting Fediverse routes file
*/
$routes->group('@(:podcastHandle)', static function ($routes): void {
$routes->post('posts/new', 'PostController::attemptCreate/$1', [
$routes->post('posts/new', 'PostController::createAction/$1', [
'as' => 'post-attempt-create',
'filter' => 'permission:podcast#.manage-publications',
'filter' => 'permission:podcast$1.manage-publications',
]);
// Post
$routes->group('posts/(:uuid)', static function ($routes): void {
@ -259,13 +264,13 @@ $routes->group('@(:podcastHandle)', static function ($routes): void {
'filter' => 'allow-cors',
]);
// Actions
$routes->post('action', 'PostController::attemptAction/$1/$2', [
$routes->post('action', 'PostController::action/$1/$2', [
'as' => 'post-attempt-action',
'filter' => 'permission:podcast#.interact-as',
'filter' => 'permission:podcast$1.interact-as',
]);
$routes->post(
'block-actor',
'PostController::attemptBlockActor/$1/$2',
'PostController::blockActorAction/$1/$2',
[
'as' => 'post-attempt-block-actor',
'filter' => 'permission:fediverse.manage-blocks',
@ -273,25 +278,25 @@ $routes->group('@(:podcastHandle)', static function ($routes): void {
);
$routes->post(
'block-domain',
'PostController::attemptBlockDomain/$1/$2',
'PostController::blockDomainAction/$1/$2',
[
'as' => 'post-attempt-block-domain',
'filter' => 'permission:fediverse.manage-blocks',
],
);
$routes->post('delete', 'PostController::attemptDelete/$1/$2', [
$routes->post('delete', 'PostController::deleteAction/$1/$2', [
'as' => 'post-attempt-delete',
'filter' => 'permission:podcast#.manage-publications',
'filter' => 'permission:podcast$1.manage-publications',
]);
$routes->get(
'remote/(:postAction)',
'PostController::remoteAction/$1/$2/$3',
'PostController::remoteActionAction/$1/$2/$3',
[
'as' => 'post-remote-action',
],
);
});
$routes->get('follow', 'ActorController::follow/$1', [
$routes->get('follow', 'ActorController::followView/$1', [
'as' => 'follow',
]);
$routes->get('outbox', 'ActorController::outbox/$1', [

View file

@ -98,6 +98,15 @@ class Routing extends BaseRouting
*/
public bool $autoRoute = false;
/**
* If TRUE, the system will look for attributes on controller
* class and methods that can run before and after the
* controller/method.
*
* If FALSE, will ignore any attributes.
*/
public bool $useControllerAttributes = true;
/**
* For Defined Routes.
* If TRUE, will enable the use of the 'prioritize' option
@ -136,7 +145,7 @@ class Routing extends BaseRouting
*
* If you enable this, $translateURIDashes is ignored.
*
* Default: true
* Default: false
*/
public bool $translateUriToCamelCase = true;
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Config;
use App\Libraries\Breadcrumb;
use App\Libraries\HtmlHead;
use App\Libraries\Negotiate;
use App\Libraries\Router;
use CodeIgniter\Config\BaseService;
@ -33,7 +34,7 @@ class Services extends BaseService
public static function router(
?RouteCollectionInterface $routes = null,
?Request $request = null,
bool $getShared = true
bool $getShared = true,
): CodeIgniterRouter {
if ($getShared) {
return static::getSharedInstance('router', $routes, $request);
@ -51,7 +52,7 @@ class Services extends BaseService
*/
public static function negotiator(
?RequestInterface $request = null,
bool $getShared = true
bool $getShared = true,
): CodeIgniterHTTPNegotiate {
if ($getShared) {
return static::getSharedInstance('negotiator', $request);
@ -70,4 +71,13 @@ class Services extends BaseService
return new Breadcrumb();
}
public static function html_head(bool $getShared = true): HtmlHead
{
if ($getShared) {
return self::getSharedInstance('html_head');
}
return new HtmlHead();
}
}

View file

@ -16,6 +16,7 @@ class Session extends BaseConfig
* --------------------------------------------------------------------------
*
* The session storage driver to use:
* - `CodeIgniter\Session\Handlers\ArrayHandler` (for testing)
* - `CodeIgniter\Session\Handlers\FileHandler`
* - `CodeIgniter\Session\Handlers\DatabaseHandler`
* - `CodeIgniter\Session\Handlers\MemcachedHandler`

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Config;
use CodeIgniter\Tasks\Config\Tasks as BaseTasks;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Tasks\Scheduler;
class Tasks extends BaseTasks
class Tasks extends BaseConfig
{
/**
* --------------------------------------------------------------------------
@ -17,7 +17,7 @@ class Tasks extends BaseTasks
* If true, will log the time it takes for each task to run.
* Requires the settings table to have been created previously.
*/
public bool $logPerformance = true;
public bool $logPerformance = false;
/**
* --------------------------------------------------------------------------

View file

@ -117,4 +117,29 @@ class Toolbar extends BaseConfig
* @var list<string>
*/
public array $watchedExtensions = ['php', 'css', 'js', 'html', 'svg', 'json', 'env'];
/**
* --------------------------------------------------------------------------
* Ignored HTTP Headers
* --------------------------------------------------------------------------
*
* CodeIgniter Debug Toolbar normally injects HTML and JavaScript into every
* HTML response. This is correct for full page loads, but it breaks requests
* that expect only a clean HTML fragment.
*
* Libraries like HTMX, Unpoly, and Hotwire (Turbo) update parts of the page or
* manage navigation on the client side. Injecting the Debug Toolbar into their
* responses can cause invalid HTML, duplicated scripts, or JavaScript errors
* (such as infinite loops or "Maximum call stack size exceeded").
*
* Any request containing one of the following headers is treated as a
* client-managed or partial request, and the Debug Toolbar injection is skipped.
*
* @var array<string, string|null>
*/
public array $disableOnHeaders = [
'X-Requested-With' => 'xmlhttprequest', // AJAX requests
'HX-Request' => 'true', // HTMX requests
'X-Up-Version' => null, // Unpoly partial requests
];
}

View file

@ -232,9 +232,13 @@ class UserAgents extends BaseConfig
*/
public array $robots = [
'googlebot' => 'Googlebot',
'google-pagerenderer' => 'Google Page Renderer',
'google-read-aloud' => 'Google Read Aloud',
'google-safety' => 'Google Safety Bot',
'msnbot' => 'MSNBot',
'baiduspider' => 'Baiduspider',
'bingbot' => 'Bing',
'bingpreview' => 'BingPreview',
'slurp' => 'Inktomi Slurp',
'yahoo' => 'Yahoo',
'ask jeeves' => 'Ask Jeeves',
@ -250,5 +254,11 @@ class UserAgents extends BaseConfig
'ia_archiver' => 'Alexa Crawler',
'MJ12bot' => 'Majestic-12',
'Uptimebot' => 'Uptimebot',
'duckduckbot' => 'DuckDuckBot',
'sogou' => 'Sogou Spider',
'exabot' => 'Exabot',
'bot' => 'Generic Bot',
'crawler' => 'Generic Crawler',
'spider' => 'Generic Spider',
];
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Config;
use App\Validation\FileRules as AppFileRules;
use App\Validation\OtherRules;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Validation\StrictRules\CreditCardRules;
use CodeIgniter\Validation\StrictRules\FileRules;
@ -24,6 +25,7 @@ class Validation extends BaseConfig
FileRules::class,
CreditCardRules::class,
AppFileRules::class,
OtherRules::class,
];
/**

View file

@ -54,4 +54,21 @@ class View extends BaseView
* @var list<class-string<ViewDecoratorInterface>>
*/
public array $decorators = [Decorator::class];
/**
* Subdirectory within app/Views for namespaced view overrides.
*
* Namespaced views will be searched in:
*
* app/Views/{$appOverridesFolder}/{Namespace}/{view_path}.{php|html...}
*
* This allows application-level overrides for package or module views
* without modifying vendor source files.
*
* Examples:
* 'overrides' -> app/Views/overrides/Example/Blog/post/card.php
* 'vendor' -> app/Views/vendor/Example/Blog/post/card.php
* '' -> app/Views/Example/Blog/post/card.php (direct mapping)
*/
public string $appOverridesFolder = 'overrides';
}

View file

@ -1,27 +1,42 @@
<?php
// app/Config/Vite.php
declare(strict_types=1);
namespace Config;
use CodeIgniterVite\Config\Vite as CodeIgniterViteConfig;
use CodeIgniterVite\Config\Vite as ViteConfig;
class Vite extends CodeIgniterViteConfig
class Vite extends ViteConfig
{
public string $environment = 'production';
public function __construct()
{
parent::__construct();
public string $serverOrigin = 'http://localhost:5173';
$adminGateway = config('Admin')
->gateway;
$installGateway = config('Install')
->gateway;
public string $resourcesDir = 'resources';
public string $assetsDir = 'assets';
public string $manifest = '.vite/manifest.json';
public string $manifestCacheName = 'vite-manifest';
/**
* @var array<array{routes:list<string>,exclude?:list<string>,assets:list<string>}>
*/
public array $routesAssets = [];
$this->routesAssets = [
[
'routes' => ['*'],
'exclude' => [$adminGateway . '*', $installGateway . '*'],
'assets' => ['styles/site.css', 'js/app.ts', 'js/podcast.ts', 'js/audio-player.ts'],
],
[
'routes' => ['/map'],
'assets' => ['js/map.ts'],
],
[
'routes' => ['/' . $adminGateway . '*'],
'assets' => ['styles/admin.css', 'js/admin.ts', 'js/admin-audio-player.ts'],
],
[
'routes' => [$installGateway . '*'],
'assets' => ['styles/install.css'],
],
];
}
}

52
app/Config/WorkerMode.php Normal file
View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Config;
/**
* This configuration controls how CodeIgniter behaves when running
* in worker mode (with FrankenPHP).
*/
class WorkerMode
{
/**
* Persistent Services
*
* List of service names that should persist across requests.
* These services will NOT be reset between requests.
*
* Services not in this list will be reset for each request to prevent
* state leakage.
*
* Recommended persistent services:
* - `autoloader`: PSR-4 autoloading configuration
* - `locator`: File locator
* - `exceptions`: Exception handler
* - `commands`: CLI commands registry
* - `codeigniter`: Main application instance
* - `superglobals`: Superglobals wrapper
* - `routes`: Router configuration
* - `cache`: Cache instance
*
* @var list<string>
*/
public array $persistentServices = [
'autoloader',
'locator',
'exceptions',
'commands',
'codeigniter',
'superglobals',
'routes',
'cache',
];
/**
* Force Garbage Collection
*
* Whether to force garbage collection after each request.
* Helps prevent memory leaks at a small performance cost.
*/
public bool $forceGarbageCollection = true;
}

View file

@ -22,19 +22,16 @@ class ActorController extends FediverseActorController
*/
protected $helpers = ['svg', 'components', 'misc', 'seo'];
public function follow(): string
public function followView(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
// @phpstan-ignore-next-line
$this->registerPodcastWebpageHit($this->actor->podcast->id);
}
// @phpstan-ignore-next-line
$this->registerPodcastWebpageHit($this->actor->podcast->id);
helper(['form', 'components', 'svg']);
// @phpstan-ignore-next-line
set_follow_metatags($this->actor);
$data = [
// @phpstan-ignore-next-line
'metatags' => get_follow_metatags($this->actor),
'actor' => $this->actor,
'actor' => $this->actor,
];
return view('podcast/follow', $data);

View file

@ -5,35 +5,25 @@ declare(strict_types=1);
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface;
use Override;
use Psr\Log\LoggerInterface;
use ViewThemes\Theme;
/**
* BaseController provides a convenient place for loading components and performing functions that are needed by all
* your controllers. Extend this class in any new controllers: class Home extends BaseController
* your controllers.
*
* For security be sure to declare any new methods as protected or private.
* Extend this class in any new controllers:
* ```
* class Home extends BaseController
* ```
*
* For security, be sure to declare any new methods as protected or private.
*/
abstract class BaseController extends Controller
{
/**
* Instance of the main Request object.
*
* @var IncomingRequest
*/
protected $request;
/**
* Instance of the main response object.
*
* @var Response
*/
protected $response;
/**
* An array of helpers to be loaded automatically upon
* class instantiation. These helpers will be available
@ -49,21 +39,19 @@ abstract class BaseController extends Controller
*/
// protected $session;
#[Override]
public function initController(
RequestInterface $request,
ResponseInterface $response,
LoggerInterface $logger
LoggerInterface $logger,
): void {
// Load here all helpers you want to be available in your controllers that extend BaseController.
// Caution: Do not put the this below the parent::initController() call below.
$this->helpers = [...$this->helpers, 'svg', 'components', 'misc', 'seo', 'premium_podcasts'];
// Caution: Do not edit this line.
// Do Not Edit This Line
parent::initController($request, $response, $logger);
// Preload any models, libraries, etc, here.
// $this->session = service('session');
Theme::setTheme('app');
}
}

View file

@ -11,18 +11,11 @@ declare(strict_types=1);
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface;
class ColorsController extends Controller
{
/**
* Instance of the main response object.
*
* @var Response
*/
protected $response;
public function index(): Response
public function index(): ResponseInterface
{
$cacheName = 'colors.css';
if (

View file

@ -33,8 +33,10 @@ class CreditsController extends BaseController
'content_markdown' => '',
]);
$allPodcasts = (new PodcastModel())->findAll();
$allCredits = (new CreditModel())->findAll();
$allPodcasts = new PodcastModel()
->findAll();
$allCredits = new CreditModel()
->findAll();
// Unlike the carpenter, we make a tree from a table:
$personGroup = null;
@ -164,10 +166,10 @@ class CreditsController extends BaseController
}
}
set_page_metatags($page);
$data = [
'metatags' => get_page_metatags($page),
'page' => $page,
'credits' => $credits,
'page' => $page,
'credits' => $credits,
];
$found = view('pages/credits', $data);

View file

@ -16,7 +16,6 @@ use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
@ -24,17 +23,11 @@ use CodeIgniter\HTTP\URI;
use Modules\Analytics\Config\Analytics;
use Modules\PremiumPodcasts\Entities\Subscription;
use Modules\PremiumPodcasts\Models\SubscriptionModel;
use Override;
use Psr\Log\LoggerInterface;
class EpisodeAudioController extends Controller
{
/**
* Instance of the main Request object.
*
* @var IncomingRequest
*/
protected $request;
/**
* An array of helpers to be loaded automatically upon class instantiation. These helpers will be available to all
* other controllers that extend Analytics.
@ -49,10 +42,11 @@ class EpisodeAudioController extends Controller
protected Analytics $analyticsConfig;
#[Override]
public function initController(
RequestInterface $request,
ResponseInterface $response,
LoggerInterface $logger
LoggerInterface $logger,
): void {
// Do Not Edit This Line
parent::initController($request, $response, $logger);
@ -71,7 +65,7 @@ class EpisodeAudioController extends Controller
}
if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast
! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) {
throw PageNotFoundException::forPageNotFound();
}
@ -79,7 +73,7 @@ class EpisodeAudioController extends Controller
$this->podcast = $podcast;
if (
! ($episode = (new EpisodeModel())->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
! ($episode = new EpisodeModel()->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
) {
throw PageNotFoundException::forPageNotFound();
}
@ -99,7 +93,7 @@ class EpisodeAudioController extends Controller
// check if podcast is already unlocked before any token validation
if ($this->episode->is_premium && ! ($subscription = service('premium_podcasts')->subscription(
$this->episode->podcast->handle
$this->episode->podcast->handle,
)) instanceof Subscription) {
// look for token as GET parameter
if (($token = $this->request->getGet('token')) === null) {
@ -114,9 +108,9 @@ class EpisodeAudioController extends Controller
}
// check if there's a valid subscription for the provided token
if (! ($subscription = (new SubscriptionModel())->validateSubscription(
if (! ($subscription = new SubscriptionModel()->validateSubscription(
$this->episode->podcast->handle,
$token
$token,
)) instanceof Subscription) {
return $this->response->setStatusCode(401, 'Invalid token!')
->setJSON([
@ -160,7 +154,7 @@ class EpisodeAudioController extends Controller
$audioDuration,
$this->episode->published_at->getTimestamp(),
$serviceName,
$subscription instanceof Subscription ? $subscription->id : null
$subscription instanceof Subscription ? $subscription->id : null,
);
$audioFileURI = new URI(service('file_manager')->getUrl($this->episode->audio->file_key));

View file

@ -19,7 +19,7 @@ use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface;
use Modules\Analytics\AnalyticsTrait;
use Modules\Fediverse\Entities\Actor;
use Modules\Fediverse\Objects\OrderedCollectionObject;
@ -44,7 +44,7 @@ class EpisodeCommentController extends BaseController
}
if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast
! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) {
throw PageNotFoundException::forPageNotFound();
}
@ -53,7 +53,7 @@ class EpisodeCommentController extends BaseController
$this->actor = $podcast->actor;
if (
! ($episode = (new EpisodeModel())->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
! ($episode = new EpisodeModel()->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
) {
throw PageNotFoundException::forPageNotFound();
}
@ -61,7 +61,7 @@ class EpisodeCommentController extends BaseController
$this->episode = $episode;
if (
! ($comment = (new EpisodeCommentModel())->getCommentById($params[2])) instanceof EpisodeComment
! ($comment = new EpisodeCommentModel()->getCommentById($params[2])) instanceof EpisodeComment
) {
throw PageNotFoundException::forPageNotFound();
}
@ -77,10 +77,7 @@ class EpisodeCommentController extends BaseController
public function view(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id);
}
$this->registerPodcastWebpageHit($this->podcast->id);
$cacheName = implode(
'_',
@ -96,12 +93,12 @@ class EpisodeCommentController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
set_episode_comment_metatags($this->comment);
$data = [
'metatags' => get_episode_comment_metatags($this->comment),
'podcast' => $this->podcast,
'actor' => $this->actor,
'episode' => $this->episode,
'comment' => $this->comment,
'podcast' => $this->podcast,
'actor' => $this->actor,
'episode' => $this->episode,
'comment' => $this->comment,
];
// if user is logged in then send to the authenticated activity view
@ -119,7 +116,7 @@ class EpisodeCommentController extends BaseController
return $cachedView;
}
public function commentObject(): Response
public function commentObject(): ResponseInterface
{
$commentObject = new CommentObject($this->comment);
@ -128,7 +125,7 @@ class EpisodeCommentController extends BaseController
->setBody($commentObject->toJSON());
}
public function replies(): Response
public function replies(): ResponseInterface
{
/**
* get comment replies
@ -148,11 +145,9 @@ class EpisodeCommentController extends BaseController
$pager = $commentReplies->pager;
$orderedItems = [];
if ($paginatedReplies !== null) {
foreach ($paginatedReplies as $reply) {
$replyObject = new CommentObject($reply);
$orderedItems[] = $replyObject;
}
foreach ($paginatedReplies as $reply) {
$replyObject = new CommentObject($reply);
$orderedItems[] = $replyObject;
}
$collection = new OrderedCollectionPage($pager, $orderedItems);
@ -163,7 +158,7 @@ class EpisodeCommentController extends BaseController
->setBody($collection->toJSON());
}
public function attemptLike(): RedirectResponse
public function likeAction(): RedirectResponse
{
if (! ($interactAsActor = interact_as_actor()) instanceof Actor) {
return redirect()->back();
@ -175,7 +170,7 @@ class EpisodeCommentController extends BaseController
return redirect()->back();
}
public function attemptReply(): RedirectResponse
public function replyAction(): RedirectResponse
{
if (! ($interactAsActor = interact_as_actor()) instanceof Actor) {
return redirect()->back();

View file

@ -18,7 +18,6 @@ use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Embed;
use Modules\Analytics\AnalyticsTrait;
@ -42,7 +41,7 @@ class EpisodeController extends BaseController
}
if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast
! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) {
throw PageNotFoundException::forPageNotFound();
}
@ -50,7 +49,7 @@ class EpisodeController extends BaseController
$this->podcast = $podcast;
if (
! ($episode = (new EpisodeModel())->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
! ($episode = new EpisodeModel()->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
) {
throw PageNotFoundException::forPageNotFound();
}
@ -65,10 +64,7 @@ class EpisodeController extends BaseController
public function index(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->episode->podcast_id);
}
$this->registerPodcastWebpageHit($this->episode->podcast_id);
$cacheName = implode(
'_',
@ -85,15 +81,14 @@ class EpisodeController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
set_episode_metatags($this->episode);
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
'podcast' => $this->podcast,
'episode' => $this->episode,
];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($this->podcast->id);
if (auth()->loggedIn()) {
helper('form');
@ -113,10 +108,7 @@ class EpisodeController extends BaseController
public function activity(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->episode->podcast_id);
}
$this->registerPodcastWebpageHit($this->episode->podcast_id);
$cacheName = implode(
'_',
@ -134,15 +126,14 @@ class EpisodeController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
set_episode_metatags($this->episode);
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
'podcast' => $this->podcast,
'episode' => $this->episode,
];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($this->podcast->id);
if (auth()->loggedIn()) {
helper('form');
@ -162,10 +153,7 @@ class EpisodeController extends BaseController
public function chapters(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->episode->podcast_id);
}
$this->registerPodcastWebpageHit($this->episode->podcast_id);
$cacheName = implode(
'_',
@ -183,13 +171,13 @@ class EpisodeController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
// get chapters from json file
set_episode_metatags($this->episode);
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
'podcast' => $this->podcast,
'episode' => $this->episode,
];
// get chapters from json file
if (isset($this->episode->chapters->file_key)) {
/** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
@ -199,9 +187,8 @@ class EpisodeController extends BaseController
$data['chapters'] = $chapters;
}
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($this->podcast->id);
if (auth()->loggedIn()) {
helper('form');
@ -221,10 +208,7 @@ class EpisodeController extends BaseController
public function transcript(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->episode->podcast_id);
}
$this->registerPodcastWebpageHit($this->episode->podcast_id);
$cacheName = implode(
'_',
@ -242,13 +226,13 @@ class EpisodeController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
// get transcript from json file
set_episode_metatags($this->episode);
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
'podcast' => $this->podcast,
'episode' => $this->episode,
];
// get transcript from json file
if ($this->episode->transcript !== null) {
$data['transcript'] = $this->episode->transcript;
@ -256,16 +240,15 @@ class EpisodeController extends BaseController
/** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
$transcriptJsonString = (string) $fileManager->getFileContents(
$this->episode->transcript->json_key
$this->episode->transcript->json_key,
);
$data['captions'] = json_decode($transcriptJsonString, true);
}
}
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($this->podcast->id);
if (auth()->loggedIn()) {
helper('form');
@ -287,10 +270,7 @@ class EpisodeController extends BaseController
{
header('Content-Security-Policy: frame-ancestors http://*:* https://*:*');
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->episode->podcast_id);
}
$this->registerPodcastWebpageHit($this->episode->podcast_id);
$session = service('session');
@ -322,9 +302,8 @@ class EpisodeController extends BaseController
'themeData' => $themeData,
];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($this->podcast->id);
// The page cache is set to a decade so it is deleted manually upon podcast update
return view('embed', $data, [
@ -381,7 +360,7 @@ class EpisodeController extends BaseController
'<iframe src="' .
$this->episode->embed_url .
'" width="100%" height="' . config(
Embed::class
Embed::class,
)->height . '" frameborder="0" scrolling="no"></iframe>',
),
);
@ -392,7 +371,7 @@ class EpisodeController extends BaseController
return $this->response->setXML($oembed);
}
public function episodeObject(): Response
public function episodeObject(): ResponseInterface
{
$podcastObject = new PodcastEpisode($this->episode);
@ -401,7 +380,7 @@ class EpisodeController extends BaseController
->setBody($podcastObject->toJSON());
}
public function comments(): Response
public function comments(): ResponseInterface
{
/**
* get comments: aggregated replies from posts referring to the episode
@ -424,10 +403,8 @@ class EpisodeController extends BaseController
$pager = $episodeComments->pager;
$orderedItems = [];
if ($paginatedComments !== null) {
foreach ($paginatedComments as $comment) {
$orderedItems[] = (new NoteObject($comment))->toArray();
}
foreach ($paginatedComments as $comment) {
$orderedItems[] = new NoteObject($comment)->toArray();
}
// @phpstan-ignore-next-line

View file

@ -26,7 +26,8 @@ class EpisodePreviewController extends BaseController
}
// find episode by previewUUID
$episode = (new EpisodeModel())->getEpisodeByPreviewId($params[0]);
$episode = new EpisodeModel()
->getEpisodeByPreviewId($params[0]);
if (! $episode instanceof Episode) {
throw PageNotFoundException::forPageNotFound();
@ -99,7 +100,7 @@ class EpisodePreviewController extends BaseController
/** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
$transcriptJsonString = (string) $fileManager->getFileContents(
$this->episode->transcript->json_key
$this->episode->transcript->json_key,
);
$data['captions'] = json_decode($transcriptJsonString, true);

View file

@ -33,7 +33,8 @@ class FeedController extends Controller
public function index(string $podcastHandle): ResponseInterface
{
$podcast = (new PodcastModel())->where('handle', $podcastHandle)
$podcast = new PodcastModel()
->where('handle', $podcastHandle)
->first();
if (! $podcast instanceof Podcast) {
throw PageNotFoundException::forPageNotFound();
@ -45,7 +46,7 @@ class FeedController extends Controller
if ($redirectToNewFeed && $podcast->new_feed_url !== null && filter_var(
$podcast->new_feed_url,
FILTER_VALIDATE_URL
FILTER_VALIDATE_URL,
) && $podcast->new_feed_url !== current_url()) {
return redirect()->to($podcast->new_feed_url, 301);
}
@ -68,7 +69,8 @@ class FeedController extends Controller
$subscription = null;
$token = $this->request->getGet('token');
if ($token) {
$subscription = (new SubscriptionModel())->validateSubscription($podcastHandle, $token);
$subscription = new SubscriptionModel()
->validateSubscription($podcastHandle, $token);
}
$cacheName = implode(
@ -85,9 +87,8 @@ class FeedController extends Controller
$found = get_rss_feed($podcast, $serviceSlug, $subscription, $token);
// The page cache is set to expire after next episode publication or a decade by default so it is deleted manually upon podcast update
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($podcast->id);
cache()
->save($cacheName, $found, $secondsToNextUnpublishedEpisode ?: DECADE);

View file

@ -22,19 +22,20 @@ class HomeController extends BaseController
{
$sortOptions = ['activity', 'created_desc', 'created_asc'];
$sortBy = in_array($this->request->getGet('sort'), $sortOptions, true) ? $this->request->getGet(
'sort'
'sort',
) : 'activity';
$allPodcasts = (new PodcastModel())->getAllPodcasts($sortBy);
$allPodcasts = new PodcastModel()
->getAllPodcasts($sortBy);
// check if there's only one podcast to redirect user to it
if (count($allPodcasts) === 1) {
return redirect()->route('podcast-activity', [$allPodcasts[0]->handle]);
}
set_home_metatags();
// default behavior: list all podcasts on home page
$data = [
'metatags' => get_home_metatags(),
'podcasts' => $allPodcasts,
'sortBy' => $sortBy,
];

View file

@ -43,7 +43,7 @@ class MapController extends BaseController
{
$cacheName = 'episodes_markers';
if (! ($found = cache($cacheName))) {
$episodes = (new EpisodeModel())
$episodes = new EpisodeModel()
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->where('location_geo is not')
->findAll();

View file

@ -24,7 +24,8 @@ class PageController extends BaseController
throw PageNotFoundException::forPageNotFound();
}
$page = (new PageModel())->where('slug', $params[0])->first();
$page = new PageModel()
->where('slug', $params[0])->first();
if (! $page instanceof Page) {
throw PageNotFoundException::forPageNotFound();
}
@ -49,9 +50,9 @@ class PageController extends BaseController
);
if (! ($found = cache($cacheName))) {
set_page_metatags($this->page);
$data = [
'metatags' => get_page_metatags($this->page),
'page' => $this->page,
'page' => $this->page,
];
$found = view('pages/page', $data);

View file

@ -17,7 +17,7 @@ use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use App\Models\PostModel;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface;
use Modules\Analytics\AnalyticsTrait;
use Modules\Fediverse\Objects\OrderedCollectionObject;
use Modules\Fediverse\Objects\OrderedCollectionPage;
@ -35,7 +35,7 @@ class PodcastController extends BaseController
}
if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast
! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) {
throw PageNotFoundException::forPageNotFound();
}
@ -47,7 +47,7 @@ class PodcastController extends BaseController
return $this->{$method}(...$params);
}
public function podcastActor(): Response
public function podcastActor(): ResponseInterface
{
$podcastActor = new PodcastActor($this->podcast);
@ -58,10 +58,7 @@ class PodcastController extends BaseController
public function activity(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id);
}
$this->registerPodcastWebpageHit($this->podcast->id);
$cacheName = implode(
'_',
@ -78,10 +75,11 @@ class PodcastController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
set_podcast_metatags($this->podcast, 'activity');
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'activity'),
'podcast' => $this->podcast,
'posts' => (new PostModel())->getActorPublishedPosts($this->podcast->actor_id),
'podcast' => $this->podcast,
'posts' => new PostModel()
->getActorPublishedPosts($this->podcast->actor_id),
];
// if user is logged in then send to the authenticated activity view
@ -91,9 +89,8 @@ class PodcastController extends BaseController
return view('podcast/activity', $data);
}
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($this->podcast->id);
return view('podcast/activity', $data, [
'cache' => $secondsToNextUnpublishedEpisode ?: DECADE,
@ -106,10 +103,7 @@ class PodcastController extends BaseController
public function about(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id);
}
$this->registerPodcastWebpageHit($this->podcast->id);
$cacheName = implode(
'_',
@ -126,12 +120,13 @@ class PodcastController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
$stats = (new EpisodeModel())->getPodcastStats($this->podcast->id);
$stats = new EpisodeModel()
->getPodcastStats($this->podcast->id);
set_podcast_metatags($this->podcast, 'about');
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'about'),
'podcast' => $this->podcast,
'stats' => $stats,
'podcast' => $this->podcast,
'stats' => $stats,
];
// // if user is logged in then send to the authenticated activity view
@ -141,9 +136,8 @@ class PodcastController extends BaseController
return view('podcast/about', $data);
}
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($this->podcast->id);
return view('podcast/about', $data, [
'cache' => $secondsToNextUnpublishedEpisode ?: DECADE,
@ -156,16 +150,14 @@ class PodcastController extends BaseController
public function episodes(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id);
}
$this->registerPodcastWebpageHit($this->podcast->id);
$yearQuery = $this->request->getGet('year');
$seasonQuery = $this->request->getGet('season');
if (! $yearQuery && ! $seasonQuery) {
$defaultQuery = (new PodcastModel())->getDefaultQuery($this->podcast->id);
$defaultQuery = new PodcastModel()
->getDefaultQuery($this->podcast->id);
if ($defaultQuery) {
if ($defaultQuery['type'] === 'season') {
$seasonQuery = $defaultQuery['data']['season_number'];
@ -245,26 +237,21 @@ class PodcastController extends BaseController
];
}
set_podcast_metatags($this->podcast, 'episodes');
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'episodes'),
'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery,
'episodes' => (new EpisodeModel())->getPodcastEpisodes(
$this->podcast->id,
$this->podcast->type,
$yearQuery,
$seasonQuery,
),
'episodes' => new EpisodeModel()
->getPodcastEpisodes($this->podcast->id, $this->podcast->type, $yearQuery, $seasonQuery),
];
if (auth()->loggedIn()) {
return view('podcast/episodes', $data);
}
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$this->podcast->id,
);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($this->podcast->id);
return view('podcast/episodes', $data, [
'cache' => $secondsToNextUnpublishedEpisode ?: DECADE,
'cache_name' => $cacheName,
@ -274,7 +261,7 @@ class PodcastController extends BaseController
return $cachedView;
}
public function episodeCollection(): Response
public function episodeCollection(): ResponseInterface
{
if ($this->podcast->type === 'serial') {
// podcast is serial
@ -298,10 +285,8 @@ class PodcastController extends BaseController
$pager = $episodes->pager;
$orderedItems = [];
if ($paginatedEpisodes !== null) {
foreach ($paginatedEpisodes as $episode) {
$orderedItems[] = (new PodcastEpisode($episode))->toArray();
}
foreach ($paginatedEpisodes as $episode) {
$orderedItems[] = new PodcastEpisode($episode)->toArray();
}
// @phpstan-ignore-next-line
@ -315,9 +300,9 @@ class PodcastController extends BaseController
public function links(): string
{
set_podcast_metatags($this->podcast, 'links');
return view('podcast/links', [
'metatags' => get_podcast_metatags($this->podcast, 'links'),
'podcast' => $this->podcast,
'podcast' => $this->podcast,
]);
}
}

View file

@ -22,6 +22,7 @@ use CodeIgniter\HTTP\URI;
use CodeIgniter\I18n\Time;
use Modules\Analytics\AnalyticsTrait;
use Modules\Fediverse\Controllers\PostController as FediversePostController;
use Override;
class PostController extends FediversePostController
{
@ -41,10 +42,12 @@ class PostController extends FediversePostController
*/
protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo', 'premium_podcasts'];
#[Override]
public function _remap(string $method, string ...$params): mixed
{
if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast
! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) {
throw PageNotFoundException::forPageNotFound();
}
@ -59,7 +62,7 @@ class PostController extends FediversePostController
}
if (
! ($post = (new PostModel())->getPostById($params[1])) instanceof CastopodPost
! ($post = new PostModel()->getPostById($params[1])) instanceof CastopodPost
) {
throw PageNotFoundException::forPageNotFound();
}
@ -79,10 +82,7 @@ class PostController extends FediversePostController
public function view(): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id);
}
$this->registerPodcastWebpageHit($this->podcast->id);
$cacheName = implode(
'_',
@ -97,10 +97,10 @@ class PostController extends FediversePostController
);
if (! ($cachedView = cache($cacheName))) {
set_post_metatags($this->post);
$data = [
'metatags' => get_post_metatags($this->post),
'post' => $this->post,
'podcast' => $this->podcast,
'post' => $this->post,
'podcast' => $this->podcast,
];
// if user is logged in then send to the authenticated activity view
@ -118,7 +118,8 @@ class PostController extends FediversePostController
return $cachedView;
}
public function attemptCreate(): RedirectResponse
#[Override]
public function createAction(): RedirectResponse
{
$rules = [
'message' => 'required|max_length[500]',
@ -147,7 +148,7 @@ class PostController extends FediversePostController
if (
$episodeUri &&
($params = extract_params_from_episode_uri(new URI($episodeUri))) &&
($episode = (new EpisodeModel())->getEpisodeBySlug($params['podcastHandle'], $params['episodeSlug']))
($episode = new EpisodeModel()->getEpisodeBySlug($params['podcastHandle'], $params['episodeSlug']))
) {
$newPost->episode_id = $episode->id;
}
@ -169,7 +170,8 @@ class PostController extends FediversePostController
return redirect()->back();
}
public function attemptReply(): RedirectResponse
#[Override]
public function replyAction(): RedirectResponse
{
$rules = [
'message' => 'required|max_length[500]',
@ -209,21 +211,24 @@ class PostController extends FediversePostController
return redirect()->back();
}
public function attemptFavourite(): RedirectResponse
#[Override]
public function favouriteAction(): RedirectResponse
{
model('FavouriteModel')->toggleFavourite(interact_as_actor(), $this->post);
return redirect()->back();
}
public function attemptReblog(): RedirectResponse
#[Override]
public function reblogAction(): RedirectResponse
{
(new PostModel())->toggleReblog(interact_as_actor(), $this->post);
new PostModel()
->toggleReblog(interact_as_actor(), $this->post);
return redirect()->back();
}
public function attemptAction(): RedirectResponse
public function action(): RedirectResponse
{
$rules = [
'action' => 'required|in_list[favourite,reblog,reply]',
@ -240,9 +245,9 @@ class PostController extends FediversePostController
$action = $validData['action'];
return match ($action) {
'favourite' => $this->attemptFavourite(),
'reblog' => $this->attemptReblog(),
'reply' => $this->attemptReply(),
'favourite' => $this->favouriteAction(),
'reblog' => $this->reblogAction(),
'reply' => $this->replyAction(),
default => redirect()
->back()
->withInput()
@ -250,19 +255,16 @@ class PostController extends FediversePostController
};
}
public function remoteAction(string $action): string
public function remoteActionView(string $action): string
{
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id);
}
$this->registerPodcastWebpageHit($this->podcast->id);
set_remote_actions_metatags($this->post, $action);
$data = [
'metatags' => get_remote_actions_metatags($this->post, $action),
'podcast' => $this->podcast,
'actor' => $this->actor,
'post' => $this->post,
'action' => $action,
'podcast' => $this->podcast,
'actor' => $this->actor,
'post' => $this->post,
'action' => $action,
];
helper('form');

View file

@ -21,7 +21,7 @@ class WebmanifestController extends Controller
/**
* @var array<string, array<string, string>>
*/
final public const THEME_COLORS = [
final public const array THEME_COLORS = [
'pine' => [
'theme' => '#009486',
'background' => '#F0F9F8',
@ -82,7 +82,7 @@ class WebmanifestController extends Controller
public function podcastManifest(string $podcastHandle): ResponseInterface
{
if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($podcastHandle)) instanceof Podcast
! ($podcast = new PodcastModel()->getPodcastByHandle($podcastHandle)) instanceof Podcast
) {
throw PageNotFoundException::forPageNotFound();
}

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddCategories extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -45,6 +48,7 @@ class AddCategories extends BaseMigration
$this->forge->createTable('categories');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('categories');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddLanguages extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -31,6 +34,7 @@ class AddLanguages extends BaseMigration
$this->forge->createTable('languages');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('languages');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPodcasts extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -205,6 +208,7 @@ class AddPodcasts extends BaseMigration
$this->forge->createTable('podcasts');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('podcasts');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddEpisodes extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -171,6 +174,7 @@ class AddEpisodes extends BaseMigration
$this->db->query($createQuery);
}
#[Override]
public function down(): void
{
$this->forge->dropTable('episodes');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPlatforms extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -41,12 +44,13 @@ class AddPlatforms extends BaseMigration
]);
$this->forge->addField('`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP()');
$this->forge->addField(
'`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP()'
'`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP()',
);
$this->forge->addPrimaryKey('slug');
$this->forge->createTable('platforms');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('platforms');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPodcastsPlatforms extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -52,6 +55,7 @@ class AddPodcastsPlatforms extends BaseMigration
$this->forge->createTable('podcasts_platforms');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('podcasts_platforms');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddEpisodeComments extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -71,6 +74,7 @@ class AddEpisodeComments extends BaseMigration
$this->forge->createTable('episode_comments');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('episode_comments');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddLikes extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -34,6 +37,7 @@ class AddLikes extends BaseMigration
$this->forge->createTable('likes');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('likes');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPages extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -48,6 +51,7 @@ class AddPages extends BaseMigration
$this->forge->createTable('pages');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('pages');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPodcastsCategories extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -32,6 +35,7 @@ class AddPodcastsCategories extends BaseMigration
$this->forge->createTable('podcasts_categories');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('podcasts_categories');

View file

@ -10,8 +10,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddClips extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -94,6 +97,7 @@ class AddClips extends BaseMigration
$this->forge->createTable('clips');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('clips');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPersons extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -67,6 +70,7 @@ class AddPersons extends BaseMigration
$this->forge->createTable('persons');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('persons');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPodcastsPersons extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -46,6 +49,7 @@ class AddPodcastsPersons extends BaseMigration
$this->forge->createTable('podcasts_persons');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('podcasts_persons');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddEpisodesPersons extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -51,6 +54,7 @@ class AddEpisodesPersons extends BaseMigration
$this->forge->createTable('episodes_persons');
}
#[Override]
public function down(): void
{
$this->forge->dropTable('episodes_persons');

View file

@ -10,8 +10,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddCreditsView extends BaseMigration
{
#[Override]
public function up(): void
{
// Creates View for credit UNION query
@ -37,6 +40,7 @@ class AddCreditsView extends BaseMigration
$this->db->query($createQuery);
}
#[Override]
public function down(): void
{
$viewName = $this->db->prefixTable('credits');

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddEpisodeIdToPosts extends BaseMigration
{
#[Override]
public function up(): void
{
$prefix = $this->db->getPrefix();
@ -33,11 +36,12 @@ class AddEpisodeIdToPosts extends BaseMigration
'id',
'',
'CASCADE',
$prefix . 'fediverse_posts_episode_id_foreign'
$prefix . 'fediverse_posts_episode_id_foreign',
);
$this->forge->processIndexes('fediverse_posts');
}
#[Override]
public function down(): void
{
$prefix = $this->db->getPrefix();

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddCreatedByToPosts extends BaseMigration
{
#[Override]
public function up(): void
{
$prefix = $this->db->getPrefix();
@ -33,11 +36,12 @@ class AddCreatedByToPosts extends BaseMigration
'id',
'',
'CASCADE',
$prefix . 'fediverse_posts_created_by_foreign'
$prefix . 'fediverse_posts_created_by_foreign',
);
$this->forge->processIndexes('fediverse_posts');
}
#[Override]
public function down(): void
{
$prefix = $this->db->getPrefix();

View file

@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddFullTextSearchIndexes extends BaseMigration
{
#[Override]
public function up(): void
{
$prefix = $this->db->getPrefix();
@ -31,6 +34,7 @@ class AddFullTextSearchIndexes extends BaseMigration
$this->db->query($createQuery);
}
#[Override]
public function down(): void
{
$prefix = $this->db->getPrefix();

View file

@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddEpisodePreviewId extends BaseMigration
{
#[Override]
public function up(): void
{
$fields = [
@ -28,6 +31,7 @@ class AddEpisodePreviewId extends BaseMigration
$this->db->query($uniquePreviewId);
}
#[Override]
public function down(): void
{
$fields = ['preview_id'];

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPodcastsOwnerEmailRemovedFromFeed extends BaseMigration
{
#[Override]
public function up(): void
{
$fields = [
@ -28,6 +31,7 @@ class AddPodcastsOwnerEmailRemovedFromFeed extends BaseMigration
$this->forge->addColumn('podcasts', $fields);
}
#[Override]
public function down(): void
{
$fields = ['is_owner_email_removed_from_feed'];

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPodcastsMediumField extends BaseMigration
{
#[Override]
public function up(): void
{
$fields = [
@ -28,6 +31,7 @@ class AddPodcastsMediumField extends BaseMigration
$this->forge->addColumn('podcasts', $fields);
}
#[Override]
public function down(): void
{
$fields = ['medium'];

View file

@ -12,8 +12,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use Override;
class AddPodcastsVerifyTxtField extends BaseMigration
{
#[Override]
public function up(): void
{
$fields = [
@ -27,6 +30,7 @@ class AddPodcastsVerifyTxtField extends BaseMigration
$this->forge->addColumn('podcasts', $fields);
}
#[Override]
public function down(): void
{
$this->forge->dropColumn('podcasts', 'verify_txt');

View file

@ -5,9 +5,11 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
use Override;
class RefactorPlatforms extends Migration
{
#[Override]
public function up(): void
{
$this->forge->addField([
@ -80,6 +82,7 @@ class RefactorPlatforms extends Migration
$this->forge->renameTable('platforms_temp', 'platforms');
}
#[Override]
public function down(): void
{
// delete platforms
@ -111,7 +114,7 @@ class RefactorPlatforms extends Migration
]);
$this->forge->addField('`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP()');
$this->forge->addField(
'`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP()'
'`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP() ON UPDATE CURRENT_TIMESTAMP()',
);
$this->forge->addPrimaryKey('slug');
$this->forge->createTable('platforms');

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
use Override;
/**
* CodeIgniter 4.5.1 introduces new DataCaster class that breaks deserialization of import queue tasks.
@ -12,11 +13,13 @@ use CodeIgniter\Database\Migration;
*/
class ClearImportQueue extends Migration
{
#[Override]
public function up(): void
{
service('settings')->forget('Import.queue');
}
#[Override]
public function down(): void
{
// nothing

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* Class AddPodcastsMediumField adds medium field to podcast table in database
*
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Database\Migrations;
use Override;
class DropDeprecatedPodcastsFields extends BaseMigration
{
#[Override]
public function up(): void
{
// TODO: migrate data
$this->forge->dropColumn(
'podcasts',
'episode_description_footer_markdown,episode_description_footer_html,is_owner_email_removed_from_feed,medium,payment_pointer,verify_txt,custom_rss,partner_id,partner_link_url,partner_image_url',
);
}
#[Override]
public function down(): void
{
$fields = [
'episode_description_footer_markdown' => [
'type' => 'TEXT',
'null' => true,
],
'episode_description_footer_html' => [
'type' => 'TEXT',
'null' => true,
],
'is_owner_email_removed_from_feed' => [
'type' => 'BOOLEAN',
'null' => false,
'default' => 0,
'after' => 'owner_email',
],
'medium' => [
'type' => "ENUM('podcast','music','audiobook')",
'null' => false,
'default' => 'podcast',
'after' => 'type',
],
'payment_pointer' => [
'type' => 'VARCHAR',
'constraint' => 128,
'comment' => 'Wallet address for Web Monetization payments',
'null' => true,
],
'verify_txt' => [
'type' => 'TEXT',
'null' => true,
'after' => 'location_osm',
],
'custom_rss' => [
'type' => 'JSON',
'null' => true,
],
'partner_id' => [
'type' => 'VARCHAR',
'constraint' => 32,
'null' => true,
],
'partner_link_url' => [
'type' => 'VARCHAR',
'constraint' => 512,
'null' => true,
],
'partner_image_url' => [
'type' => 'VARCHAR',
'constraint' => 512,
'null' => true,
],
];
$this->forge->addColumn('podcasts', $fields);
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/**
* Class AddPodcastsMediumField adds medium field to podcast table in database
*
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Database\Migrations;
use Override;
class DropDeprecatedEpisodesFields extends BaseMigration
{
#[Override]
public function up(): void
{
$this->forge->dropColumn('episodes', 'custom_rss');
}
#[Override]
public function down(): void
{
$fields = [
'custom_rss' => [
'type' => 'JSON',
'null' => true,
],
];
$this->forge->addColumn('episodes', $fields);
}
}

View file

@ -14,6 +14,7 @@ namespace App\Database\Migrations;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\Migration;
use Override;
class BaseMigration extends Migration
{
@ -24,10 +25,12 @@ class BaseMigration extends Migration
*/
protected $db;
#[Override]
public function up(): void
{
}
#[Override]
public function down(): void
{
}

View file

@ -13,9 +13,11 @@ declare(strict_types=1);
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
use Override;
class AppSeeder extends Seeder
{
#[Override]
public function run(): void
{
$this->call('CategorySeeder');

View file

@ -13,9 +13,11 @@ declare(strict_types=1);
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
use Override;
class CategorySeeder extends Seeder
{
#[Override]
public function run(): void
{
$data = [

View file

@ -13,9 +13,11 @@ declare(strict_types=1);
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
use Override;
class DevSeeder extends Seeder
{
#[Override]
public function run(): void
{
$this->call('CategorySeeder');

View file

@ -15,12 +15,14 @@ namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
use CodeIgniter\Shield\Entities\User;
use Modules\Auth\Models\UserModel;
use Override;
class DevSuperadminSeeder extends Seeder
{
#[Override]
public function run(): void
{
if ((new UserModel())->where('is_owner', true)->first() instanceof User) {
if (new UserModel()->where('is_owner', true)->first() instanceof User) {
return;
}

View file

@ -20,9 +20,11 @@ use CodeIgniter\Database\Seeder;
use Exception;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use Override;
class FakePodcastsAnalyticsSeeder extends Seeder
{
#[Override]
public function run(): void
{
$jsonUserAgents = json_decode(
@ -41,13 +43,14 @@ class FakePodcastsAnalyticsSeeder extends Seeder
JSON_THROW_ON_ERROR,
);
$podcast = (new PodcastModel())->first();
$podcast = new PodcastModel()
->first();
if (! $podcast instanceof Podcast) {
throw new Exception("COULD NOT POPULATE DATABASE:\n\tCreate a podcast with episodes first.\n");
}
$firstEpisode = (new EpisodeModel())
$firstEpisode = new EpisodeModel()
->selectMin('published_at')
->first();
@ -67,7 +70,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder
$analyticsPodcastsByPlayer = [];
$analyticsPodcastsByRegion = [];
$episodes = (new EpisodeModel())
$episodes = new EpisodeModel()
->where('podcast_id', $podcast->id)
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->findAll();

View file

@ -18,6 +18,7 @@ use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\Database\Seeder;
use Exception;
use Override;
class FakeWebsiteAnalyticsSeeder extends Seeder
{
@ -181,15 +182,17 @@ class FakeWebsiteAnalyticsSeeder extends Seeder
'WOSBrowser',
];
#[Override]
public function run(): void
{
$podcast = (new PodcastModel())->first();
$podcast = new PodcastModel()
->first();
if (! $podcast instanceof Podcast) {
throw new Exception("COULD NOT POPULATE DATABASE:\n\tCreate a podcast with episodes first.\n");
}
$firstEpisode = (new EpisodeModel())
$firstEpisode = new EpisodeModel()
->selectMin('published_at')
->first();
@ -206,7 +209,7 @@ class FakeWebsiteAnalyticsSeeder extends Seeder
$websiteByEntryPage = [];
$websiteByReferer = [];
$episodes = (new EpisodeModel())
$episodes = new EpisodeModel()
->where('podcast_id', $podcast->id)
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->findAll();

View file

@ -18,9 +18,11 @@ declare(strict_types=1);
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
use Override;
class LanguageSeeder extends Seeder
{
#[Override]
public function run(): void
{
$data = [

View file

@ -12,6 +12,7 @@ namespace App\Entities;
use App\Models\PodcastModel;
use Modules\Fediverse\Entities\Actor as FediverseActor;
use Override;
/**
* @property Podcast|null $podcast
@ -31,12 +32,14 @@ class Actor extends FediverseActor
public function getPodcast(): ?Podcast
{
if (! $this->podcast instanceof Podcast) {
$this->podcast = (new PodcastModel())->getPodcastByActorId($this->id);
$this->podcast = new PodcastModel()
->getPodcastByActorId($this->id);
}
return $this->podcast;
}
#[Override]
public function getAvatarImageUrl(): string
{
if ($this->podcast instanceof Podcast) {
@ -46,6 +49,7 @@ class Actor extends FediverseActor
return parent::getAvatarImageUrl();
}
#[Override]
public function getAvatarImageMimetype(): string
{
if ($this->podcast instanceof Podcast) {

View file

@ -15,7 +15,7 @@ use CodeIgniter\Entity\Entity;
/**
* @property int $id
* @property int|null $parent_id
* @property ?int $parent_id
* @property Category|null $parent
* @property string $code
* @property string $apple_category
@ -42,6 +42,7 @@ class Category extends Entity
return null;
}
return (new CategoryModel())->getCategoryById($this->parent_id);
return new CategoryModel()
->getCategoryById($this->parent_id);
}
}

View file

@ -31,7 +31,7 @@ use Modules\Media\Models\MediaModel;
* @property Episode $episode
* @property string $title
* @property double $start_time
* @property double $end_time
* @property ?double $end_time
* @property double $duration
* @property string $type
* @property int|null $media_id
@ -81,14 +81,6 @@ class BaseClip extends Entity
'updated_by' => 'integer',
];
/**
* @param array<string, mixed>|null $data
*/
public function __construct(array $data = null)
{
parent::__construct($data);
}
public function getJobDuration(): ?int
{
if ($this->job_duration === null && $this->job_started_at && $this->job_ended_at) {
@ -110,18 +102,21 @@ class BaseClip extends Entity
public function getPodcast(): ?Podcast
{
return (new PodcastModel())->getPodcastById($this->podcast_id);
return new PodcastModel()
->getPodcastById($this->podcast_id);
}
public function getEpisode(): ?Episode
{
return (new EpisodeModel())->getEpisodeById($this->episode_id);
return new EpisodeModel()
->getEpisodeById($this->episode_id);
}
public function getUser(): ?User
{
/** @var ?User */
return (new UserModel())->find($this->created_by);
return new UserModel()
->find($this->created_by);
}
public function setMedia(File $file, string $fileKey): static
@ -131,7 +126,8 @@ class BaseClip extends Entity
->setFile($file);
$this->getMedia()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('audio'))->updateMedia($this->getMedia());
new MediaModel('audio')
->updateMedia($this->getMedia());
} else {
$media = new Audio([
'file_key' => $fileKey,
@ -142,7 +138,7 @@ class BaseClip extends Entity
]);
$media->setFile($file);
$this->attributes['media_id'] = (new MediaModel())->saveMedia($media);
$this->attributes['media_id'] = new MediaModel()->saveMedia($media);
}
return $this;
@ -151,7 +147,8 @@ class BaseClip extends Entity
public function getMedia(): Audio | Video | null
{
if ($this->media_id !== null && $this->media === null) {
$this->media = (new MediaModel($this->type))->getMediaById($this->media_id);
$this->media = new MediaModel($this->type)
->getMediaById($this->media_id);
}
return $this->media;

View file

@ -13,6 +13,7 @@ namespace App\Entities\Clip;
use CodeIgniter\Files\File;
use Modules\Media\Entities\Video;
use Modules\Media\Models\MediaModel;
use Override;
/**
* @property array{name:string,preview:string} $theme
@ -25,7 +26,7 @@ class VideoClip extends BaseClip
/**
* @param array<string, mixed>|null $data
*/
public function __construct(array $data = null)
public function __construct(?array $data = null)
{
parent::__construct($data);
@ -36,7 +37,7 @@ class VideoClip extends BaseClip
}
/**
* @param array<string, string> $theme
* @param array{name:string,preview:string} $theme
*/
public function setTheme(array $theme): self
{
@ -63,6 +64,7 @@ class VideoClip extends BaseClip
return $this;
}
#[Override]
public function setMedia(File $file, string $fileKey): static
{
if ($this->attributes['media_id'] !== null) {
@ -79,7 +81,7 @@ class VideoClip extends BaseClip
]);
$video->setFile($file);
$this->attributes['media_id'] = (new MediaModel('video'))->saveMedia($video);
$this->attributes['media_id'] = new MediaModel('video')->saveMedia($video);
return $this;
}

View file

@ -56,7 +56,8 @@ class Credit extends Entity
public function getPerson(): ?Person
{
if (! $this->person instanceof Person) {
$this->person = (new PersonModel())->getPersonById($this->person_id);
$this->person = new PersonModel()
->getPersonById($this->person_id);
}
return $this->person;
@ -65,7 +66,8 @@ class Credit extends Entity
public function getPodcast(): ?Podcast
{
if (! $this->podcast instanceof Podcast) {
$this->podcast = (new PodcastModel())->getPodcastById($this->podcast_id);
$this->podcast = new PodcastModel()
->getPodcastById($this->podcast_id);
}
return $this->podcast;
@ -78,7 +80,8 @@ class Credit extends Entity
}
if (! $this->episode instanceof Episode) {
$this->episode = (new EpisodeModel())->getPublishedEpisodeById($this->podcast_id, $this->episode_id);
$this->episode = new EpisodeModel()
->getPublishedEpisodeById($this->podcast_id, $this->episode_id);
}
return $this->episode;
@ -86,6 +89,10 @@ class Credit extends Entity
public function getGroupLabel(): string
{
if ($this->person_group === '') {
return '';
}
/** @var string */
return lang("PersonsTaxonomy.persons.{$this->person_group}.label");
}

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace App\Entities;
use App\Entities\Clip\Soundbite;
use App\Libraries\SimpleRSSElement;
use App\Models\ClipModel;
use App\Models\EpisodeCommentModel;
use App\Models\EpisodeModel;
@ -29,13 +28,12 @@ use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\MarkdownConverter;
use Modules\Analytics\OP3;
use Modules\Media\Entities\Audio;
use Modules\Media\Entities\Chapters;
use Modules\Media\Entities\Image;
use Modules\Media\Entities\Transcript;
use Modules\Media\Models\MediaModel;
use SimpleXMLElement;
use Override;
/**
* @property int $id
@ -64,16 +62,14 @@ use SimpleXMLElement;
* @property Chapters|null $chapters
* @property string|null $chapters_remote_url
* @property string|null $parental_advisory
* @property int $number
* @property int|null $season_number
* @property ?int $number
* @property ?int $season_number
* @property string $type
* @property bool $is_blocked
* @property Location|null $location
* @property string|null $location_name
* @property string|null $location_geo
* @property string|null $location_osm
* @property array<string|int,mixed>|null $custom_rss
* @property string $custom_rss_string
* @property bool $is_published_on_hubs
* @property int $downloads_count
* @property int $posts_count
@ -93,19 +89,19 @@ use SimpleXMLElement;
*/
class Episode extends Entity
{
protected Podcast $podcast;
public string $link = '';
protected string $link;
public string $audio_url = '';
public string $audio_web_url = '';
public string $audio_opengraph_url = '';
protected Podcast $podcast;
protected ?Audio $audio = null;
protected string $audio_url;
protected string $audio_web_url;
protected string $audio_opengraph_url;
protected string $embed_url;
protected string $embed_url = '';
protected ?Image $cover = null;
@ -137,8 +133,6 @@ class Episode extends Entity
protected ?Location $location = null;
protected string $custom_rss_string;
protected ?string $publication_status = null;
/**
@ -173,7 +167,6 @@ class Episode extends Entity
'location_name' => '?string',
'location_geo' => '?string',
'location_osm' => '?string',
'custom_rss' => '?json-array',
'is_published_on_hubs' => 'boolean',
'downloads_count' => 'integer',
'posts_count' => 'integer',
@ -183,7 +176,32 @@ class Episode extends Entity
'updated_by' => 'integer',
];
public function setCover(UploadedFile | File $file = null): self
/**
* @param array<string, mixed> $data
*/
#[Override]
public function injectRawData(array $data): static
{
parent::injectRawData($data);
$this->link = url_to('episode', esc($this->getPodcast()->handle, 'url'), esc($this->attributes['slug'], 'url'));
$this->audio_url = url_to(
'episode-audio',
$this->getPodcast()
->handle,
$this->slug,
$this->getAudio()
->file_extension,
);
$this->audio_opengraph_url = $this->audio_url . '?_from=-+Open+Graph+-';
$this->audio_web_url = $this->audio_url . '?_from=-+Website+-';
return $this;
}
public function setCover(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
@ -194,7 +212,8 @@ class Episode extends Entity
->setFile($file);
$this->getCover()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getCover());
new MediaModel('image')
->updateMedia($this->getCover());
} else {
$cover = new Image([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '.' . $file->getExtension(),
@ -205,7 +224,7 @@ class Episode extends Entity
]);
$cover->setFile($file);
$this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover);
$this->attributes['cover_id'] = new MediaModel('image')->saveMedia($cover);
}
return $this;
@ -224,12 +243,13 @@ class Episode extends Entity
return $this->cover;
}
$this->cover = (new MediaModel('image'))->getMediaById($this->cover_id);
$this->cover = new MediaModel('image')
->getMediaById($this->cover_id);
return $this->cover;
}
public function setAudio(UploadedFile | File $file = null): self
public function setAudio(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
@ -240,7 +260,8 @@ class Episode extends Entity
->setFile($file);
$this->getAudio()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('audio'))->updateMedia($this->getAudio());
new MediaModel('audio')
->updateMedia($this->getAudio());
} else {
$audio = new Audio([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $file->getRandomName(),
@ -251,7 +272,7 @@ class Episode extends Entity
]);
$audio->setFile($file);
$this->attributes['audio_id'] = (new MediaModel())->saveMedia($audio);
$this->attributes['audio_id'] = new MediaModel()->saveMedia($audio);
}
return $this;
@ -260,13 +281,14 @@ class Episode extends Entity
public function getAudio(): Audio
{
if (! $this->audio instanceof Audio) {
$this->audio = (new MediaModel('audio'))->getMediaById($this->audio_id);
$this->audio = new MediaModel('audio')
->getMediaById($this->audio_id);
}
return $this->audio;
}
public function setTranscript(UploadedFile | File $file = null): self
public function setTranscript(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
@ -277,7 +299,8 @@ class Episode extends Entity
->setFile($file);
$this->getTranscript()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('transcript'))->updateMedia($this->getTranscript());
new MediaModel('transcript')
->updateMedia($this->getTranscript());
} else {
$transcript = new Transcript([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-transcript.' . $file->getExtension(),
@ -288,7 +311,7 @@ class Episode extends Entity
]);
$transcript->setFile($file);
$this->attributes['transcript_id'] = (new MediaModel('transcript'))->saveMedia($transcript);
$this->attributes['transcript_id'] = new MediaModel('transcript')->saveMedia($transcript);
}
return $this;
@ -297,13 +320,14 @@ class Episode extends Entity
public function getTranscript(): ?Transcript
{
if ($this->transcript_id !== null && ! $this->transcript instanceof Transcript) {
$this->transcript = (new MediaModel('transcript'))->getMediaById($this->transcript_id);
$this->transcript = new MediaModel('transcript')
->getMediaById($this->transcript_id);
}
return $this->transcript;
}
public function setChapters(UploadedFile | File $file = null): self
public function setChapters(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
@ -314,7 +338,8 @@ class Episode extends Entity
->setFile($file);
$this->getChapters()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('chapters'))->updateMedia($this->getChapters());
new MediaModel('chapters')
->updateMedia($this->getChapters());
} else {
$chapters = new Chapters([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-chapters' . '.' . $file->getExtension(),
@ -325,7 +350,7 @@ class Episode extends Entity
]);
$chapters->setFile($file);
$this->attributes['chapters_id'] = (new MediaModel('chapters'))->saveMedia($chapters);
$this->attributes['chapters_id'] = new MediaModel('chapters')->saveMedia($chapters);
}
return $this;
@ -334,46 +359,13 @@ class Episode extends Entity
public function getChapters(): ?Chapters
{
if ($this->chapters_id !== null && ! $this->chapters instanceof Chapters) {
$this->chapters = (new MediaModel('chapters'))->getMediaById($this->chapters_id);
$this->chapters = new MediaModel('chapters')
->getMediaById($this->chapters_id);
}
return $this->chapters;
}
public function getAudioUrl(): string
{
$audioURL = url_to(
'episode-audio',
$this->getPodcast()
->handle,
$this->slug,
$this->getAudio()
->file_extension
);
// Wrap episode url with OP3 if episode is public and OP3 is enabled on this podcast
if (! $this->is_premium && service('settings')->get(
'Analytics.enableOP3',
'podcast:' . $this->podcast_id
)) {
$op3 = new OP3(config('Analytics')->OP3);
return $op3->wrap($audioURL, $this);
}
return $audioURL;
}
public function getAudioWebUrl(): string
{
return $this->getAudioUrl() . '?_from=-+Website+-';
}
public function getAudioOpengraphUrl(): string
{
return $this->getAudioUrl() . '?_from=-+Open+Graph+-';
}
/**
* Gets transcript url from transcript file uri if it exists or returns the transcript_remote_url which can be null.
*/
@ -406,7 +398,8 @@ class Episode extends Entity
public function getPersons(): array
{
if ($this->persons === null) {
$this->persons = (new PersonModel())->getEpisodePersons($this->podcast_id, $this->id);
$this->persons = new PersonModel()
->getEpisodePersons($this->podcast_id, $this->id);
}
return $this->persons;
@ -420,7 +413,8 @@ class Episode extends Entity
public function getSoundbites(): array
{
if ($this->soundbites === null) {
$this->soundbites = (new ClipModel())->getEpisodeSoundbites($this->getPodcast()->id, $this->id);
$this->soundbites = new ClipModel()
->getEpisodeSoundbites($this->getPodcast()->id, $this->id);
}
return $this->soundbites;
@ -432,7 +426,8 @@ class Episode extends Entity
public function getPosts(): array
{
if ($this->posts === null) {
$this->posts = (new PostModel())->getEpisodePosts($this->id);
$this->posts = new PostModel()
->getEpisodePosts($this->id);
}
return $this->posts;
@ -444,18 +439,14 @@ class Episode extends Entity
public function getComments(): array
{
if ($this->comments === null) {
$this->comments = (new EpisodeCommentModel())->getEpisodeComments($this->id);
$this->comments = new EpisodeCommentModel()
->getEpisodeComments($this->id);
}
return $this->comments;
}
public function getLink(): string
{
return url_to('episode', esc($this->getPodcast()->handle), esc($this->attributes['slug']));
}
public function getEmbedUrl(string $theme = null): string
public function getEmbedUrl(?string $theme = null): string
{
return $theme
? url_to('embed-theme', esc($this->getPodcast()->handle), esc($this->attributes['slug']), $theme)
@ -464,14 +455,15 @@ class Episode extends Entity
public function setGuid(?string $guid = null): static
{
$this->attributes['guid'] = $guid ?? $this->getLink();
$this->attributes['guid'] = $guid ?? $this->link;
return $this;
}
public function getPodcast(): ?Podcast
{
return (new PodcastModel())->getPodcastById($this->podcast_id);
return new PodcastModel()
->getPodcastById($this->podcast_id);
}
public function setDescriptionMarkdown(string $descriptionMarkdown): static
@ -495,34 +487,6 @@ class Episode extends Entity
return $this;
}
public function getDescriptionHtml(?string $serviceSlug = null): string
{
$descriptionHtml = '';
if (
$this->getPodcast()
->partner_id !== null &&
$this->getPodcast()
->partner_link_url !== null &&
$this->getPodcast()
->partner_image_url !== null
) {
$descriptionHtml .= "<div><a href=\"{$this->getPartnerLink(
$serviceSlug,
)}\" rel=\"sponsored noopener noreferrer\" target=\"_blank\"><img src=\"{$this->getPartnerImageUrl(
$serviceSlug,
)}\" alt=\"Partner image\" /></a></div>";
}
$descriptionHtml .= $this->attributes['description_html'];
if ($this->getPodcast()->episode_description_footer_html) {
$descriptionHtml .= "<footer>{$this->getPodcast()
->episode_description_footer_html}</footer>";
}
return $descriptionHtml;
}
public function getDescription(): string
{
if ($this->description === null) {
@ -591,96 +555,11 @@ class Episode extends Entity
return $this->location;
}
/**
* Get custom rss tag as XML String
*/
public function getCustomRssString(): string
{
if ($this->custom_rss === null) {
return '';
}
helper('rss');
$xmlNode = (new SimpleRSSElement(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"/>',
))
->addChild('channel')
->addChild('item');
array_to_rss([
'elements' => $this->custom_rss,
], $xmlNode);
return str_replace(['<item>', '</item>'], '', (string) $xmlNode->asXML());
}
/**
* Saves custom rss tag into json
*/
public function setCustomRssString(?string $customRssString = null): static
{
if ($customRssString === '') {
$this->attributes['custom_rss'] = null;
return $this;
}
helper('rss');
$customXML = simplexml_load_string(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel><item>' .
$customRssString .
'</item></channel></rss>',
);
if (! $customXML instanceof SimpleXMLElement) {
// TODO: Failed to parse custom xml, should return error?
return $this;
}
$customRssArray = rss_to_array($customXML)['elements'][0]['elements'][0];
if (array_key_exists('elements', $customRssArray)) {
$this->attributes['custom_rss'] = json_encode($customRssArray['elements']);
} else {
$this->attributes['custom_rss'] = null;
}
return $this;
}
public function getPartnerLink(?string $serviceSlug = null): string
{
$partnerLink =
rtrim((string) $this->getPodcast()->partner_link_url, '/') .
'?pid=' .
$this->getPodcast()
->partner_id .
'&guid=' .
urlencode((string) $this->attributes['guid']);
if ($serviceSlug !== null) {
$partnerLink .= '&_from=' . $serviceSlug;
}
return $partnerLink;
}
public function getPartnerImageUrl(string $serviceSlug = null): string
{
return rtrim((string) $this->getPodcast()->partner_image_url, '/') .
'?pid=' .
$this->getPodcast()
->partner_id .
'&guid=' .
urlencode((string) $this->attributes['guid']) .
($serviceSlug !== null ? '&_from=' . $serviceSlug : '');
}
public function getPreviewLink(): string
{
if ($this->preview_id === null) {
// generate preview id
if (! $previewUUID = (new EpisodeModel())->setEpisodePreviewId($this->id)) {
if (! $previewUUID = new EpisodeModel()->setEpisodePreviewId($this->id)) {
throw new Exception('Could not set episode preview id');
}
@ -695,6 +574,7 @@ class Episode extends Entity
*/
public function getClipCount(): int|string
{
return (new ClipModel())->getClipCount($this->podcast_id, $this->id);
return new ClipModel()
->getClipCount($this->podcast_id, $this->id);
}
}

View file

@ -24,7 +24,7 @@ use RuntimeException;
* @property Episode|null $episode
* @property int $actor_id
* @property Actor|null $actor
* @property string|null $in_reply_to_id
* @property ?string $in_reply_to_id
* @property EpisodeComment|null $reply_to_comment
* @property string $message
* @property string $message_html
@ -76,7 +76,8 @@ class EpisodeComment extends UuidEntity
public function getEpisode(): ?Episode
{
if (! $this->episode instanceof Episode) {
$this->episode = (new EpisodeModel())->getEpisodeById($this->episode_id);
$this->episode = new EpisodeModel()
->getEpisodeById($this->episode_id);
}
return $this->episode;
@ -100,9 +101,9 @@ class EpisodeComment extends UuidEntity
*/
public function getReplies(): array
{
if ($this->replies === null) {
$this->replies = (new EpisodeCommentModel())->getCommentReplies($this->id);
$this->replies = new EpisodeCommentModel()
->getCommentReplies($this->id);
}
return $this->replies;

View file

@ -22,15 +22,9 @@ use CodeIgniter\Entity\Entity;
*/
class Location extends Entity
{
/**
* @var string
*/
private const OSM_URL = 'https://www.openstreetmap.org/';
private const string OSM_URL = 'https://www.openstreetmap.org/';
/**
* @var string
*/
private const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/';
private const string NOMINATIM_URL = 'https://nominatim.openstreetmap.org/';
public function __construct(
protected string $name,
@ -108,14 +102,14 @@ class Location extends Entity
if (property_exists($places[0], 'lat') && $places[0]->lat !== null && (property_exists(
$places[0],
'lon'
'lon',
) && $places[0]->lon !== null)) {
$this->attributes['geo'] = "geo:{$places[0]->lat},{$places[0]->lon}";
}
if (property_exists($places[0], 'osm_type') && $places[0]->osm_type !== null && (property_exists(
$places[0],
'osm_id'
'osm_id',
) && $places[0]->osm_id !== null)) {
$this->attributes['osm'] = strtoupper(substr((string) $places[0]->osm_type, 0, 1)) . $places[0]->osm_id;
}

View file

@ -23,7 +23,7 @@ use RuntimeException;
* @property string $full_name
* @property string $unique_name
* @property string|null $information_url
* @property int|null $avatar_id
* @property ?int $avatar_id
* @property ?Image $avatar
* @property int $created_by
* @property int $updated_by
@ -56,7 +56,7 @@ class Person extends Entity
/**
* Saves the person avatar in `public/media/persons/`
*/
public function setAvatar(UploadedFile | File $file = null): static
public function setAvatar(UploadedFile | File|null $file = null): static
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
@ -67,7 +67,8 @@ class Person extends Entity
->setFile($file);
$this->getAvatar()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getAvatar());
new MediaModel('image')
->updateMedia($this->getAvatar());
} else {
$avatar = new Image([
'file_key' => 'persons/' . $this->attributes['unique_name'] . '.' . $file->getExtension(),
@ -78,7 +79,7 @@ class Person extends Entity
]);
$avatar->setFile($file);
$this->attributes['avatar_id'] = (new MediaModel('image'))->saveMedia($avatar);
$this->attributes['avatar_id'] = new MediaModel('image')->saveMedia($avatar);
}
return $this;
@ -91,7 +92,8 @@ class Person extends Entity
}
if (! $this->avatar instanceof Image) {
$this->avatar = (new MediaModel('image'))->getMediaById($this->avatar_id);
$this->avatar = new MediaModel('image')
->getMediaById($this->avatar_id);
}
return $this->avatar;
@ -107,11 +109,12 @@ class Person extends Entity
}
if ($this->roles === null) {
$this->roles = (new PersonModel())->getPersonRoles(
$this->id,
(int) $this->attributes['podcast_id'],
array_key_exists('episode_id', $this->attributes) ? (int) $this->attributes['episode_id'] : null
);
$this->roles = new PersonModel()
->getPersonRoles(
$this->id,
(int) $this->attributes['podcast_id'],
array_key_exists('episode_id', $this->attributes) ? (int) $this->attributes['episode_id'] : null,
);
}
return $this->roles;

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace App\Entities;
use App\Libraries\SimpleRSSElement;
use App\Models\ActorModel;
use App\Models\CategoryModel;
use App\Models\EpisodeModel;
@ -56,18 +55,14 @@ use RuntimeException;
* @property string $language_code
* @property int $category_id
* @property Category|null $category
* @property int[]|null $other_categories_ids
* @property int[] $other_categories_ids
* @property Category[] $other_categories
* @property string|null $parental_advisory
* @property string|null $publisher
* @property string $owner_name
* @property string $owner_email
* @property bool $is_owner_email_removed_from_feed
* @property string $type
* @property string $medium
* @property string|null $copyright
* @property string|null $episode_description_footer_markdown
* @property string|null $episode_description_footer_html
* @property bool $is_blocked
* @property bool $is_completed
* @property bool $is_locked
@ -77,15 +72,7 @@ use RuntimeException;
* @property string|null $location_name
* @property string|null $location_geo
* @property string|null $location_osm
* @property string|null $payment_pointer
* @property array<string|int,mixed>|null $custom_rss
* @property bool $is_op3_enabled
* @property string $op3_url
* @property string $custom_rss_string
* @property bool $is_published_on_hubs
* @property string|null $partner_id
* @property string|null $partner_link_url
* @property string|null $partner_image_url
* @property int $created_by
* @property int $updated_by
* @property string $publication_status
@ -125,9 +112,9 @@ class Podcast extends Entity
protected ?array $other_categories = null;
/**
* @var int[]|null
* @var int[]
*/
protected ?array $other_categories_ids = null;
protected array $other_categories_ids = [];
/**
* @var Episode[]|null
@ -166,8 +153,6 @@ class Podcast extends Entity
protected ?Location $location = null;
protected string $custom_rss_string;
protected ?string $publication_status = null;
/**
@ -180,44 +165,35 @@ class Podcast extends Entity
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'guid' => 'string',
'actor_id' => 'integer',
'handle' => 'string',
'title' => 'string',
'description_markdown' => 'string',
'description_html' => 'string',
'cover_id' => 'int',
'banner_id' => '?int',
'language_code' => 'string',
'category_id' => 'integer',
'parental_advisory' => '?string',
'publisher' => '?string',
'owner_name' => 'string',
'owner_email' => 'string',
'is_owner_email_removed_from_feed' => 'boolean',
'type' => 'string',
'medium' => 'string',
'copyright' => '?string',
'episode_description_footer_markdown' => '?string',
'episode_description_footer_html' => '?string',
'is_blocked' => 'boolean',
'is_completed' => 'boolean',
'is_locked' => 'boolean',
'is_premium_by_default' => 'boolean',
'imported_feed_url' => '?string',
'new_feed_url' => '?string',
'location_name' => '?string',
'location_geo' => '?string',
'location_osm' => '?string',
'payment_pointer' => '?string',
'custom_rss' => '?json-array',
'is_published_on_hubs' => 'boolean',
'partner_id' => '?string',
'partner_link_url' => '?string',
'partner_image_url' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
'id' => 'integer',
'guid' => 'string',
'actor_id' => 'integer',
'handle' => 'string',
'title' => 'string',
'description_markdown' => 'string',
'description_html' => 'string',
'cover_id' => 'int',
'banner_id' => '?int',
'language_code' => 'string',
'category_id' => 'integer',
'parental_advisory' => '?string',
'publisher' => '?string',
'owner_name' => 'string',
'owner_email' => 'string',
'type' => 'string',
'copyright' => '?string',
'is_blocked' => 'boolean',
'is_completed' => 'boolean',
'is_locked' => 'boolean',
'is_premium_by_default' => 'boolean',
'imported_feed_url' => '?string',
'new_feed_url' => '?string',
'location_name' => '?string',
'location_geo' => '?string',
'location_osm' => '?string',
'is_published_on_hubs' => 'boolean',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public function getAtHandle(): string
@ -239,7 +215,7 @@ class Podcast extends Entity
return $this->actor;
}
public function setCover(UploadedFile | File $file = null): self
public function setCover(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
@ -250,7 +226,8 @@ class Podcast extends Entity
->setFile($file);
$this->getCover()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getCover());
new MediaModel('image')
->updateMedia($this->getCover());
} else {
$cover = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/cover.' . $file->getExtension(),
@ -261,7 +238,7 @@ class Podcast extends Entity
]);
$cover->setFile($file);
$this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover);
$this->attributes['cover_id'] = new MediaModel('image')->saveMedia($cover);
}
return $this;
@ -270,7 +247,8 @@ class Podcast extends Entity
public function getCover(): Image
{
if (! $this->cover instanceof Image) {
$cover = (new MediaModel('image'))->getMediaById($this->cover_id);
$cover = new MediaModel('image')
->getMediaById($this->cover_id);
if (! $cover instanceof Image) {
throw new Exception('Could not retrieve podcast cover.');
@ -282,7 +260,7 @@ class Podcast extends Entity
return $this->cover;
}
public function setBanner(UploadedFile | File $file = null): self
public function setBanner(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
@ -293,7 +271,8 @@ class Podcast extends Entity
->setFile($file);
$this->getBanner()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getBanner());
new MediaModel('image')
->updateMedia($this->getBanner());
} else {
$banner = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/banner.' . $file->getExtension(),
@ -304,7 +283,7 @@ class Podcast extends Entity
]);
$banner->setFile($file);
$this->attributes['banner_id'] = (new MediaModel('image'))->saveMedia($banner);
$this->attributes['banner_id'] = new MediaModel('image')->saveMedia($banner);
}
return $this;
@ -317,7 +296,8 @@ class Podcast extends Entity
}
if (! $this->banner instanceof Image) {
$this->banner = (new MediaModel('image'))->getMediaById($this->banner_id);
$this->banner = new MediaModel('image')
->getMediaById($this->banner_id);
}
return $this->banner;
@ -341,7 +321,8 @@ class Podcast extends Entity
public function getEpisodes(): array
{
if ($this->episodes === null) {
$this->episodes = (new EpisodeModel())->getPodcastEpisodes($this->id, $this->type);
$this->episodes = new EpisodeModel()
->getPodcastEpisodes($this->id, $this->type);
}
return $this->episodes;
@ -352,7 +333,8 @@ class Podcast extends Entity
*/
public function getEpisodesCount(): int|string
{
return (new EpisodeModel())->getPodcastEpisodesCount($this->id);
return new EpisodeModel()
->getPodcastEpisodesCount($this->id);
}
/**
@ -363,7 +345,8 @@ class Podcast extends Entity
public function getPersons(): array
{
if ($this->persons === null) {
$this->persons = (new PersonModel())->getPodcastPersons($this->id);
$this->persons = new PersonModel()
->getPodcastPersons($this->id);
}
return $this->persons;
@ -375,7 +358,8 @@ class Podcast extends Entity
public function getCategory(): ?Category
{
if (! $this->category instanceof Category) {
$this->category = (new CategoryModel())->getCategoryById($this->category_id);
$this->category = new CategoryModel()
->getCategoryById($this->category_id);
}
return $this->category;
@ -389,7 +373,8 @@ class Podcast extends Entity
public function getSubscriptions(): array
{
if ($this->subscriptions === null) {
$this->subscriptions = (new SubscriptionModel())->getPodcastSubscriptions($this->id);
$this->subscriptions = new SubscriptionModel()
->getPodcastSubscriptions($this->id);
}
return $this->subscriptions;
@ -403,7 +388,8 @@ class Podcast extends Entity
public function getContributors(): array
{
if ($this->contributors === null) {
$this->contributors = (new UserModel())->getPodcastContributors($this->id);
$this->contributors = new UserModel()
->getPodcastContributors($this->id);
}
return $this->contributors;
@ -430,42 +416,6 @@ class Podcast extends Entity
return $this;
}
public function setEpisodeDescriptionFooterMarkdown(?string $episodeDescriptionFooterMarkdown = null): static
{
if ($episodeDescriptionFooterMarkdown === null || $episodeDescriptionFooterMarkdown === '') {
$this->attributes[
'episode_description_footer_markdown'
] = null;
$this->attributes[
'episode_description_footer_html'
] = null;
return $this;
}
$config = [
'html_input' => 'escape',
'allow_unsafe_links' => false,
];
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
$converter = new MarkdownConverter($environment);
$this->attributes[
'episode_description_footer_markdown'
] = $episodeDescriptionFooterMarkdown;
$this->attributes[
'episode_description_footer_html'
] = $converter->convert($episodeDescriptionFooterMarkdown);
return $this;
}
public function getDescription(): string
{
if ($this->description === null) {
@ -500,7 +450,8 @@ class Podcast extends Entity
public function getPodcastingPlatforms(): array
{
if ($this->podcasting_platforms === null) {
$this->podcasting_platforms = (new PlatformModel())->getPlatforms($this->id, 'podcasting');
$this->podcasting_platforms = new PlatformModel()
->getPlatforms($this->id, 'podcasting');
}
return $this->podcasting_platforms;
@ -514,7 +465,8 @@ class Podcast extends Entity
public function getSocialPlatforms(): array
{
if ($this->social_platforms === null) {
$this->social_platforms = (new PlatformModel())->getPlatforms($this->id, 'social');
$this->social_platforms = new PlatformModel()
->getPlatforms($this->id, 'social');
}
return $this->social_platforms;
@ -527,9 +479,9 @@ class Podcast extends Entity
*/
public function getFundingPlatforms(): array
{
if ($this->funding_platforms === null) {
$this->funding_platforms = (new PlatformModel())->getPlatforms($this->id, 'funding');
$this->funding_platforms = new PlatformModel()
->getPlatforms($this->id, 'funding');
}
return $this->funding_platforms;
@ -541,18 +493,19 @@ class Podcast extends Entity
public function getOtherCategories(): array
{
if ($this->other_categories === null) {
$this->other_categories = (new CategoryModel())->getPodcastCategories($this->id);
$this->other_categories = new CategoryModel()
->getPodcastCategories($this->id);
}
return $this->other_categories;
}
/**
* @return int[]|string[]
* @return int[]
*/
public function getOtherCategoriesIds(): array
{
if ($this->other_categories_ids === null) {
if ($this->other_categories_ids === []) {
$this->other_categories_ids = array_column($this->getOtherCategories(), 'id');
}
@ -599,68 +552,10 @@ class Podcast extends Entity
return $this->location;
}
/**
* Get custom rss tag as XML String
*/
public function getCustomRssString(): string
{
if ($this->attributes['custom_rss'] === null) {
return '';
}
helper('rss');
$xmlNode = (new SimpleRSSElement(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"/>',
))->addChild('channel');
array_to_rss([
'elements' => $this->custom_rss,
], $xmlNode);
return str_replace(['<channel>', '</channel>'], '', (string) $xmlNode->asXML());
}
/**
* Saves custom rss tag into json
*/
public function setCustomRssString(string $customRssString): static
{
if ($customRssString === '') {
$this->attributes['custom_rss'] = null;
return $this;
}
helper('rss');
$customRssArray = rss_to_array(
simplexml_load_string(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel>' .
$customRssString .
'</channel></rss>',
),
)['elements'][0];
if (array_key_exists('elements', $customRssArray)) {
$this->attributes['custom_rss'] = json_encode($customRssArray['elements']);
} else {
$this->attributes['custom_rss'] = null;
}
return $this;
}
public function getIsPremium(): bool
{
// podcast is premium if at least one of its episodes is set as premium
return (new EpisodeModel())->doesPodcastHavePremiumEpisodes($this->id);
}
public function getIsOp3Enabled(): bool
{
return service('settings')->get('Analytics.enableOP3', 'podcast:' . $this->id);
}
public function getOp3Url(): string
{
return 'https://op3.dev/show/' . $this->guid;
return new EpisodeModel()
->doesPodcastHavePremiumEpisodes($this->id);
}
}

View file

@ -51,7 +51,8 @@ class Post extends FediversePost
}
if (! $this->episode instanceof Episode) {
$this->episode = (new EpisodeModel())->getEpisodeById($this->episode_id);
$this->episode = new EpisodeModel()
->getEpisodeById($this->episode_id);
}
return $this->episode;

View file

@ -7,31 +7,38 @@ namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Override;
class AllowCorsFilter implements FilterInterface
{
/**
* @param string[]|null $arguments
* @param list<string>|null $arguments
*
* @return RequestInterface|ResponseInterface|string|null
*/
#[Override]
public function before(RequestInterface $request, $arguments = null)
{
return null;
}
/**
* @param string[]|null $arguments
* @return ResponseInterface|void
* @param list<string>|null $arguments
*
* @return ResponseInterface|null
*/
#[Override]
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
if (! $response->hasHeader('Cache-Control')) {
$response->setHeader('Cache-Control', 'public, max-age=86400');
}
return $response->setHeader('Access-Control-Allow-Origin', '*') // for allowing any domain, insecure
$response->setHeader('Access-Control-Allow-Origin', '*') // for allowing any domain, insecure
->setHeader('Access-Control-Allow-Headers', '*') // for allowing any headers, insecure
->setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') // allows GET and OPTIONS methods only
->setHeader('Access-Control-Max-Age', '86400');
return $response;
}
}

View file

@ -2,12 +2,6 @@
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (! function_exists('render_breadcrumb')) {
/**
* Renders the breadcrumb navigation through the Breadcrumb service
@ -15,20 +9,18 @@ if (! function_exists('render_breadcrumb')) {
* @param string|null $class to be added to the breadcrumb nav
* @return string html breadcrumb
*/
function render_breadcrumb(string $class = null): string
function render_breadcrumb(?string $class = null): string
{
$breadcrumb = service('breadcrumb');
return $breadcrumb->render($class);
return service('breadcrumb')->render($class);
}
}
if (! function_exists('replace_breadcrumb_params')) {
/**
* @param string[] $newParams
* @param array<string|int,string> $newParams
*/
function replace_breadcrumb_params(array $newParams): void
{
$breadcrumb = service('breadcrumb');
$breadcrumb->replaceParams(esc($newParams));
service('breadcrumb')->replaceParams($newParams);
}
}

View file

@ -16,31 +16,6 @@ use CodeIgniter\View\Table;
// ------------------------------------------------------------------------
if (! function_exists('hint_tooltip')) {
/**
* Hint component
*
* Used to produce tooltip with a question mark icon for hint texts
*
* @param string $hintText The hint text
*/
function hint_tooltip(string $hintText = '', string $class = ''): string
{
$tooltip =
'<span data-tooltip="bottom" tabindex="0" title="' .
esc($hintText) .
'" class="inline-block align-middle opacity-75 focus:ring-accent';
if ($class !== '') {
$tooltip .= ' ' . $class;
}
return $tooltip . '">' . icon('question-fill') . '</span>';
}
}
// ------------------------------------------------------------------------
if (! function_exists('data_table')) {
/**
* Data table component
@ -113,12 +88,12 @@ if (! function_exists('publication_pill')) {
*/
function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string
{
$class = match ($publicationStatus) {
'published' => 'text-pine-500 border-pine-500 bg-pine-50',
'scheduled' => 'text-red-600 border-red-600 bg-red-50',
'with_podcast' => 'text-blue-600 border-blue-600 bg-blue-50',
'not_published' => 'text-gray-600 border-gray-600 bg-gray-50',
default => 'text-gray-600 border-gray-600 bg-gray-50',
$variant = match ($publicationStatus) {
'published' => 'success',
'scheduled' => 'warning',
'with_podcast' => 'info',
'not_published' => 'default',
default => 'default',
};
$title = match ($publicationStatus) {
@ -130,16 +105,12 @@ if (! function_exists('publication_pill')) {
$label = lang('Episode.publication_status.' . $publicationStatus);
return '<span ' . ($title === '' ? '' : 'title="' . $title . '"') . ' class="flex items-center px-1 font-semibold border rounded w-max ' .
$class .
' ' .
$customClass .
'">' .
$label .
($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
// @icon("error-warning-fill")
return '<x-Pill ' . ($title === '' ? '' : 'title="' . $title . '"') . ' variant="' . $variant . '" class="' . $customClass .
'">' . $label . ($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
'class' => 'flex-shrink-0 ml-1 text-lg',
]) : '') .
'</span>';
'</x-Pill>';
}
}
@ -182,7 +153,7 @@ if (! function_exists('publication_button')) {
}
return <<<HTML
<Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</Button>
<x-Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</x-Button>
HTML;
}
}
@ -263,7 +234,7 @@ if (! function_exists('episode_publication_status_banner')) {
$bannerText = lang('Episode.publication_status_banner.text', [
'publication_status' => $episode->publication_status,
'publication_date' => $episode->published_at instanceof Time ? local_datetime(
$episode->published_at
$episode->published_at,
) : null,
]);
$previewLinkLabel = lang('Episode.publication_status_banner.preview');
@ -296,7 +267,7 @@ if (! function_exists('episode_numbering')) {
?int $episodeNumber = null,
?int $seasonNumber = null,
string $class = '',
bool $isAbbr = false
bool $isAbbr = false,
): string {
if (! $episodeNumber && ! $seasonNumber) {
return '';
@ -356,7 +327,7 @@ if (! function_exists('location_link')) {
'class' => 'mr-2 flex-shrink-0',
]) . '<span class="truncate">' . esc($location->name) . '</span>',
[
'class' => 'w-full overflow-hidden inline-flex items-baseline hover:underline focus:ring-accent' .
'class' => 'w-full overflow-hidden inline-flex items-baseline hover:underline' .
($class === '' ? '' : " {$class}"),
'target' => '_blank',
'rel' => 'noreferrer noopener',
@ -411,7 +382,7 @@ if (! function_exists('relative_time')) {
function relative_time(Time $time, string $class = ''): string
{
$formatter = new IntlDateFormatter(service(
'request'
'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ATOM);
@ -432,7 +403,7 @@ if (! function_exists('local_datetime')) {
function local_datetime(Time $time): string
{
$formatter = new IntlDateFormatter(service(
'request'
'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ATOM);
@ -461,7 +432,7 @@ if (! function_exists('local_date')) {
function local_date(Time $time): string
{
$formatter = new IntlDateFormatter(service(
'request'
'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
$translatedDate = $time->toLocalizedString($formatter->getPattern());

View file

@ -23,20 +23,20 @@ if (! function_exists('form_textarea')) {
// Unsets default rows and cols if defined in extra field as array or string.
if ((is_array($extra) && array_key_exists('rows', $extra)) || (is_string($extra) && stripos(
(string) preg_replace('~\s+~', '', $extra),
'rows='
'rows=',
) !== false)) {
unset($defaults['rows']);
}
if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos(
(string) preg_replace('~\s+~', '', $extra),
'cols='
'cols=',
) !== false)) {
unset($defaults['cols']);
}
return '<textarea ' . rtrim(parse_form_attributes($data, $defaults)) . stringify_attributes(
$extra
$extra,
) . '>' . $val . "</textarea>\n";
}
}

View file

@ -206,7 +206,7 @@ if (! function_exists('get_podcast_banner')) {
if (! $podcast->banner instanceof Image) {
$defaultBanner = config('Images')
->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config(
Images::class
Images::class,
)->podcastBannerDefaultPaths['default'];
$sizes = config('Images')
@ -217,7 +217,7 @@ if (! function_exists('get_podcast_banner')) {
// return default site icon url
return base_url(
change_file_path($defaultBanner['path'], '_' . $size, $sizeConfig['extension'] ?? null)
change_file_path($defaultBanner['path'], '_' . $size, $sizeConfig['extension'] ?? null),
);
}
@ -238,7 +238,7 @@ if (! function_exists('get_podcast_banner_mimetype')) {
// return default site icon url
return array_key_exists('mimetype', $sizeConfig) ? $sizeConfig['mimetype'] : config(
Images::class
Images::class,
)->podcastBannerDefaultMimeType;
}

View file

@ -16,34 +16,35 @@ if (! function_exists('render_page_links')) {
*
* @return string html pages navigation
*/
function render_page_links(string $class = null, string $podcastHandle = null): string
function render_page_links(?string $class = null, ?string $podcastHandle = null): string
{
$pages = (new PageModel())->findAll();
$pages = new PageModel()
->findAll();
$links = anchor(route_to('home'), lang('Common.home'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
if ($podcastHandle !== null) {
$links .= anchor(route_to('podcast-links', $podcastHandle), lang('Podcast.links'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
}
$links .= anchor(route_to('credits'), lang('Person.credits'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
$links .= anchor(route_to('map'), lang('Page.map.title'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
foreach ($pages as $page) {
$links .= anchor($page->link, esc($page->title), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
}
// if set in .env, add legal notice link at the end of page links
if (config('App')->legalNoticeURL !== null) {
$links .= anchor(config('App')->legalNoticeURL, lang('Common.legal_notice'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
'target' => '_blank',
'rel' => 'noopener noreferrer',
]);

View file

@ -10,12 +10,13 @@ declare(strict_types=1);
use App\Entities\Category;
use App\Entities\Location;
use App\Entities\Podcast;
use App\Libraries\SimpleRSSElement;
use App\Libraries\RssFeed;
use App\Models\PodcastModel;
use CodeIgniter\I18n\Time;
use Config\Mimes;
use Modules\Media\Entities\Chapters;
use Modules\Media\Entities\Transcript;
use Modules\Plugins\Core\Plugins;
use Modules\PremiumPodcasts\Entities\Subscription;
if (! function_exists('get_rss_feed')) {
@ -28,24 +29,21 @@ if (! function_exists('get_rss_feed')) {
function get_rss_feed(
Podcast $podcast,
string $serviceSlug = '',
Subscription $subscription = null,
string $token = null
?Subscription $subscription = null,
?string $token = null,
): string {
/** @var Plugins $plugins */
$plugins = service('plugins');
$episodes = $podcast->episodes;
$itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
$rss = new RssFeed();
$podcastNamespace = 'https://podcastindex.org/namespace/1.0';
$atomNamespace = 'http://www.w3.org/2005/Atom';
$rss = new SimpleRSSElement(
"<?xml version='1.0' encoding='utf-8'?><rss version='2.0' xmlns:itunes='{$itunesNamespace}' xmlns:podcast='{$podcastNamespace}' xmlns:atom='{$atomNamespace}' xmlns:content='http://purl.org/rss/1.0/modules/content/'></rss>"
);
$plugins->rssBeforeChannel($podcast);
$channel = $rss->addChild('channel');
$atomLink = $channel->addChild('link', null, $atomNamespace);
$atomLink = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLink->addAttribute('href', $podcast->feed_url);
$atomLink->addAttribute('rel', 'self');
$atomLink->addAttribute('type', 'application/rss+xml');
@ -54,18 +52,18 @@ if (! function_exists('get_rss_feed')) {
$websubHubs = config('WebSub')
->hubs;
foreach ($websubHubs as $websubHub) {
$atomLinkHub = $channel->addChild('link', null, $atomNamespace);
$atomLinkHub = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLinkHub->addAttribute('href', $websubHub);
$atomLinkHub->addAttribute('rel', 'hub');
$atomLinkHub->addAttribute('type', 'application/rss+xml');
}
if ($podcast->new_feed_url !== null) {
$channel->addChild('new-feed-url', $podcast->new_feed_url, $itunesNamespace);
$channel->addChild('new-feed-url', $podcast->new_feed_url, RssFeed::ITUNES_NAMESPACE);
}
// the last build date corresponds to the creation of the feed.xml cache
$channel->addChild('lastBuildDate', (new Time('now'))->format(DATE_RFC1123));
$channel->addChild('lastBuildDate', new Time('now')->format(DATE_RFC1123));
$channel->addChild('generator', 'Castopod - https://castopod.org/');
$channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html');
@ -76,22 +74,21 @@ if (! function_exists('get_rss_feed')) {
$podcast->guid = $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $podcast->feed_url)
->toString();
(new PodcastModel())->save($podcast);
new PodcastModel()
->save($podcast);
}
$channel->addChild('guid', $podcast->guid, $podcastNamespace);
$channel->addChild('guid', $podcast->guid, RssFeed::PODCAST_NAMESPACE);
$channel->addChild('title', $podcast->title, null, false);
$channel->addChildWithCDATA('description', $podcast->description_html);
$channel->addChild('medium', $podcast->medium, $podcastNamespace);
$itunesImage = $channel->addChild('image', null, $itunesNamespace);
$itunesImage = $channel->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
$itunesImage->addAttribute('href', $podcast->cover->feed_url);
$channel->addChild('language', $podcast->language_code);
if ($podcast->location instanceof Location) {
$locationElement = $channel->addChild('location', $podcast->location->name, $podcastNamespace);
$locationElement = $channel->addChild('location', $podcast->location->name, RssFeed::PODCAST_NAMESPACE);
if ($podcast->location->geo !== null) {
$locationElement->addAttribute('geo', $podcast->location->geo);
}
@ -101,38 +98,16 @@ if (! function_exists('get_rss_feed')) {
}
}
if ($podcast->payment_pointer !== null) {
$valueElement = $channel->addChild('value', null, $podcastNamespace);
$valueElement->addAttribute('type', 'webmonetization');
$valueElement->addAttribute('method', 'ILP');
$recipientElement = $valueElement->addChild('valueRecipient', null, $podcastNamespace);
$recipientElement->addAttribute('name', $podcast->owner_name);
$recipientElement->addAttribute('type', 'paymentpointer');
$recipientElement->addAttribute('address', $podcast->payment_pointer);
$recipientElement->addAttribute('split', '100');
}
if ($podcast->is_owner_email_removed_from_feed) {
$channel
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', $podcastNamespace);
} else {
$channel
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', $podcastNamespace)
->addAttribute('owner', $podcast->owner_email);
}
if ($podcast->verify_txt !== null) {
$channel
->addChild('txt', $podcast->verify_txt, $podcastNamespace)
->addAttribute('purpose', 'verify');
}
$channel
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', RssFeed::PODCAST_NAMESPACE)
->addAttribute('owner', $podcast->owner_email);
if ($podcast->imported_feed_url !== null) {
$channel->addChild('previousUrl', $podcast->imported_feed_url, $podcastNamespace);
$channel->addChild('previousUrl', $podcast->imported_feed_url, RssFeed::PODCAST_NAMESPACE);
}
foreach ($podcast->podcasting_platforms as $podcastingPlatform) {
$podcastingPlatformElement = $channel->addChild('id', null, $podcastNamespace);
$podcastingPlatformElement = $channel->addChild('id', null, RssFeed::PODCAST_NAMESPACE);
$podcastingPlatformElement->addAttribute('platform', $podcastingPlatform->slug);
if ($podcastingPlatform->account_id !== null) {
$podcastingPlatformElement->addAttribute('id', $podcastingPlatform->account_id);
@ -141,7 +116,7 @@ if (! function_exists('get_rss_feed')) {
$podcastingPlatformElement->addAttribute('url', $podcastingPlatform->link_url);
}
$castopodSocialElement = $channel->addChild('social', null, $podcastNamespace);
$castopodSocialElement = $channel->addChild('social', null, RssFeed::PODCAST_NAMESPACE);
$castopodSocialElement->addAttribute('priority', '1');
$castopodSocialElement->addAttribute('platform', 'castopod');
$castopodSocialElement->addAttribute('protocol', 'activitypub');
@ -149,7 +124,7 @@ if (! function_exists('get_rss_feed')) {
$castopodSocialElement->addAttribute('accountUrl', $podcast->link);
foreach ($podcast->social_platforms as $socialPlatform) {
$socialElement = $channel->addChild('social', null, $podcastNamespace);
$socialElement = $channel->addChild('social', null, RssFeed::PODCAST_NAMESPACE);
$socialElement->addAttribute('priority', '2');
$socialElement->addAttribute('platform', $socialPlatform->slug);
@ -157,7 +132,7 @@ if (! function_exists('get_rss_feed')) {
if (in_array(
$socialPlatform->slug,
['mastodon', 'peertube', 'funkwhale', 'misskey', 'mobilizon', 'pixelfed', 'plume', 'writefreely'],
true
true,
)) {
$socialElement->addAttribute('protocol', 'activitypub');
} else {
@ -171,41 +146,41 @@ if (! function_exists('get_rss_feed')) {
$socialElement->addAttribute('accountUrl', esc($socialPlatform->link_url));
if ($socialPlatform->slug === 'mastodon') {
$socialSignUpelement = $socialElement->addChild('socialSignUp', null, $podcastNamespace);
$socialSignUpelement = $socialElement->addChild('socialSignUp', null, RssFeed::PODCAST_NAMESPACE);
$socialSignUpelement->addAttribute('priority', '1');
$socialSignUpelement->addAttribute(
'homeUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST
) . '/public'
PHP_URL_HOST,
) . '/public',
);
$socialSignUpelement->addAttribute(
'signUpUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST
) . '/auth/sign_up'
PHP_URL_HOST,
) . '/auth/sign_up',
);
$castopodSocialSignUpelement = $castopodSocialElement->addChild(
'socialSignUp',
null,
$podcastNamespace
RssFeed::PODCAST_NAMESPACE,
);
$castopodSocialSignUpelement->addAttribute('priority', '1');
$castopodSocialSignUpelement->addAttribute(
'homeUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST
) . '/public'
PHP_URL_HOST,
) . '/public',
);
$castopodSocialSignUpelement->addAttribute(
'signUpUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST
) . '/auth/sign_up'
PHP_URL_HOST,
) . '/auth/sign_up',
);
}
}
@ -214,7 +189,7 @@ if (! function_exists('get_rss_feed')) {
$fundingPlatformElement = $channel->addChild(
'funding',
$fundingPlatform->account_id,
$podcastNamespace,
RssFeed::PODCAST_NAMESPACE,
);
$fundingPlatformElement->addAttribute('platform', $fundingPlatform->slug);
$fundingPlatformElement->addAttribute('url', $fundingPlatform->link_url);
@ -222,9 +197,9 @@ if (! function_exists('get_rss_feed')) {
foreach ($podcast->persons as $person) {
foreach ($person->roles as $role) {
$personElement = $channel->addChild('person', $person->full_name, $podcastNamespace);
$personElement = $channel->addChild('person', $person->full_name, RssFeed::PODCAST_NAMESPACE);
$personElement->addAttribute('img', get_avatar_url($person, 'federation'));
$personElement->addAttribute('img', get_avatar_url($person, 'medium'));
if ($person->information_url !== null) {
$personElement->addAttribute('href', $person->information_url);
@ -251,29 +226,26 @@ if (! function_exists('get_rss_feed')) {
$channel->addChild(
'explicit',
$podcast->parental_advisory === 'explicit' ? 'true' : 'false',
$itunesNamespace,
RssFeed::ITUNES_NAMESPACE,
);
$channel->addChild('author', $podcast->publisher ?: $podcast->owner_name, $itunesNamespace, false);
$channel->addChild('author', $podcast->publisher ?: $podcast->owner_name, RssFeed::ITUNES_NAMESPACE, false);
$channel->addChild('link', $podcast->link);
$owner = $channel->addChild('owner', null, $itunesNamespace);
$owner = $channel->addChild('owner', null, RssFeed::ITUNES_NAMESPACE);
$owner->addChild('name', $podcast->owner_name, $itunesNamespace, false);
$owner->addChild('name', $podcast->owner_name, RssFeed::ITUNES_NAMESPACE, false);
$owner->addChild('email', $podcast->owner_email, RssFeed::ITUNES_NAMESPACE);
if (! $podcast->is_owner_email_removed_from_feed) {
$owner->addChild('email', $podcast->owner_email, $itunesNamespace);
}
$channel->addChild('type', $podcast->type, $itunesNamespace);
$channel->addChild('type', $podcast->type, RssFeed::ITUNES_NAMESPACE);
$podcast->copyright &&
$channel->addChild('copyright', $podcast->copyright);
if ($podcast->is_blocked || $subscription instanceof Subscription) {
$channel->addChild('block', 'Yes', $itunesNamespace);
$channel->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
if ($podcast->is_completed) {
$channel->addChild('complete', 'Yes', $itunesNamespace);
$channel->addChild('complete', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
$image = $channel->addChild('image');
@ -281,17 +253,16 @@ if (! function_exists('get_rss_feed')) {
$image->addChild('title', $podcast->title, null, false);
$image->addChild('link', $podcast->link);
if ($podcast->custom_rss !== null) {
array_to_rss([
'elements' => $podcast->custom_rss,
], $channel);
}
// run plugins hook at the end
$plugins->rssAfterChannel($podcast, $channel);
foreach ($episodes as $episode) {
if ($episode->is_premium && ! $subscription instanceof Subscription) {
continue;
}
$plugins->rssBeforeItem($episode);
$item = $channel->addChild('item');
$item->addChild('title', $episode->title, null, false);
$enclosure = $item->addChild('enclosure');
@ -311,7 +282,7 @@ if (! function_exists('get_rss_feed')) {
$item->addChild('guid', $episode->guid);
$item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123));
if ($episode->location instanceof Location) {
$locationElement = $item->addChild('location', $episode->location->name, $podcastNamespace);
$locationElement = $item->addChild('location', $episode->location->name, RssFeed::PODCAST_NAMESPACE);
if ($episode->location->geo !== null) {
$locationElement->addAttribute('geo', $episode->location->geo);
}
@ -321,10 +292,10 @@ if (! function_exists('get_rss_feed')) {
}
}
$item->addChildWithCDATA('description', $episode->getDescriptionHtml($serviceSlug));
$item->addChild('duration', (string) round($episode->audio->duration), $itunesNamespace);
$item->addChildWithCDATA('description', $episode->description_html);
$item->addChild('duration', (string) round($episode->audio->duration), RssFeed::ITUNES_NAMESPACE);
$item->addChild('link', $episode->link);
$episodeItunesImage = $item->addChild('image', null, $itunesNamespace);
$episodeItunesImage = $item->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
$episodeItunesImage->addAttribute('href', $episode->cover->feed_url);
$episode->parental_advisory &&
@ -333,18 +304,18 @@ if (! function_exists('get_rss_feed')) {
$episode->parental_advisory === 'explicit'
? 'true'
: 'false',
$itunesNamespace,
RssFeed::ITUNES_NAMESPACE,
);
$episode->number &&
$item->addChild('episode', (string) $episode->number, $itunesNamespace);
$item->addChild('episode', (string) $episode->number, RssFeed::ITUNES_NAMESPACE);
$episode->season_number &&
$item->addChild('season', (string) $episode->season_number, $itunesNamespace);
$item->addChild('episodeType', $episode->type, $itunesNamespace);
$item->addChild('season', (string) $episode->season_number, RssFeed::ITUNES_NAMESPACE);
$item->addChild('episodeType', $episode->type, RssFeed::ITUNES_NAMESPACE);
// If episode is of type trailer, add podcast:trailer tag on channel level
if ($episode->type === 'trailer') {
$trailer = $channel->addChild('trailer', $episode->title, $podcastNamespace);
$trailer = $channel->addChild('trailer', $episode->title, RssFeed::PODCAST_NAMESPACE);
$trailer->addAttribute('pubdate', $episode->published_at->format(DATE_RFC2822));
$trailer->addAttribute(
'url',
@ -357,43 +328,37 @@ if (! function_exists('get_rss_feed')) {
}
}
// add podcast namespace tags for season and episode
$episode->season_number &&
$item->addChild('season', (string) $episode->season_number, $podcastNamespace);
$episode->number &&
$item->addChild('episode', (string) $episode->number, $podcastNamespace);
// add link to episode comments as podcast-activity format
$comments = $item->addChild('comments', null, $podcastNamespace);
$comments = $item->addChild('comments', null, RssFeed::PODCAST_NAMESPACE);
$comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug));
$comments->addAttribute('contentType', 'application/podcast-activity+json');
if ($episode->getPosts()) {
$socialInteractUri = $episode->getPosts()[0]
->uri;
$socialInteractElement = $item->addChild('socialInteract', null, $podcastNamespace);
$socialInteractElement = $item->addChild('socialInteract', null, RssFeed::PODCAST_NAMESPACE);
$socialInteractElement->addAttribute('uri', $socialInteractUri);
$socialInteractElement->addAttribute('priority', '1');
$socialInteractElement->addAttribute('platform', 'castopod');
$socialInteractElement->addAttribute('protocol', 'activitypub');
$socialInteractElement->addAttribute(
'accountId',
"@{$podcast->actor->username}@{$podcast->actor->domain}"
"@{$podcast->actor->username}@{$podcast->actor->domain}",
);
$socialInteractElement->addAttribute(
'pubDate',
$episode->getPosts()[0]
->published_at->format(DateTime::ISO8601)
->published_at->format(DateTime::ISO8601),
);
}
if ($episode->transcript instanceof Transcript) {
$transcriptElement = $item->addChild('transcript', null, $podcastNamespace);
$transcriptElement = $item->addChild('transcript', null, RssFeed::PODCAST_NAMESPACE);
$transcriptElement->addAttribute('url', $episode->transcript->file_url);
$transcriptElement->addAttribute(
'type',
Mimes::guessTypeFromExtension(
pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION)
pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION),
) ?? 'text/html',
);
// Castopod only allows for captions (SubRip files)
@ -403,21 +368,21 @@ if (! function_exists('get_rss_feed')) {
}
if ($episode->getChapters() instanceof Chapters) {
$chaptersElement = $item->addChild('chapters', null, $podcastNamespace);
$chaptersElement = $item->addChild('chapters', null, RssFeed::PODCAST_NAMESPACE);
$chaptersElement->addAttribute('url', $episode->chapters->file_url);
$chaptersElement->addAttribute('type', 'application/json+chapters');
}
foreach ($episode->soundbites as $soundbite) {
// TODO: differentiate video from soundbites?
$soundbiteElement = $item->addChild('soundbite', $soundbite->title, $podcastNamespace);
$soundbiteElement = $item->addChild('soundbite', $soundbite->title, RssFeed::PODCAST_NAMESPACE);
$soundbiteElement->addAttribute('startTime', (string) $soundbite->start_time);
$soundbiteElement->addAttribute('duration', (string) round($soundbite->duration, 3));
}
foreach ($episode->persons as $person) {
foreach ($person->roles as $role) {
$personElement = $item->addChild('person', esc($person->full_name), $podcastNamespace);
$personElement = $item->addChild('person', esc($person->full_name), RssFeed::PODCAST_NAMESPACE);
$personElement->addAttribute(
'role',
@ -429,7 +394,7 @@ if (! function_exists('get_rss_feed')) {
esc(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')),
);
$personElement->addAttribute('img', get_avatar_url($person, 'federation'));
$personElement->addAttribute('img', get_avatar_url($person, 'medium'));
if ($person->information_url !== null) {
$personElement->addAttribute('href', $person->information_url);
@ -438,14 +403,10 @@ if (! function_exists('get_rss_feed')) {
}
if ($episode->is_blocked) {
$item->addChild('block', 'Yes', $itunesNamespace);
$item->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
if ($episode->custom_rss !== null) {
array_to_rss([
'elements' => $episode->custom_rss,
], $item);
}
$plugins->rssAfterItem($episode, $item);
}
return $rss->asXML();
@ -456,11 +417,9 @@ if (! function_exists('add_category_tag')) {
/**
* Adds <itunes:category> and <category> tags to node for a given category
*/
function add_category_tag(SimpleXMLElement $node, Category $category): void
function add_category_tag(RssFeed $node, Category $category): void
{
$itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
$itunesCategory = $node->addChild('category', null, $itunesNamespace);
$itunesCategory = $node->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategory->addAttribute(
'text',
$category->parent instanceof Category
@ -469,7 +428,7 @@ if (! function_exists('add_category_tag')) {
);
if ($category->parent instanceof Category) {
$itunesCategoryChild = $itunesCategory->addChild('category', null, $itunesNamespace);
$itunesCategoryChild = $itunesCategory->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategoryChild->addAttribute('text', $category->apple_category);
$node->addChild('category', $category->parent->apple_category);
}
@ -477,70 +436,3 @@ if (! function_exists('add_category_tag')) {
$node->addChild('category', $category->apple_category);
}
}
if (! function_exists('rss_to_array')) {
/**
* Converts XML to array
*
* FIXME: param should be SimpleRSSElement
*
* @return array<string, mixed>
*/
function rss_to_array(SimpleXMLElement $rssNode): array
{
$nameSpaces = ['', 'http://www.itunes.com/dtds/podcast-1.0.dtd', 'https://podcastindex.org/namespace/1.0'];
$arrayNode = [];
$arrayNode['name'] = $rssNode->getName();
$arrayNode['namespace'] = $rssNode->getNamespaces(false);
foreach ($rssNode->attributes() as $key => $value) {
$arrayNode['attributes'][$key] = (string) $value;
}
$textcontent = trim((string) $rssNode);
if ($textcontent !== '') {
$arrayNode['content'] = $textcontent;
}
foreach ($nameSpaces as $currentNameSpace) {
foreach ($rssNode->children($currentNameSpace) as $childXmlNode) {
$arrayNode['elements'][] = rss_to_array($childXmlNode);
}
}
return $arrayNode;
}
}
if (! function_exists('array_to_rss')) {
/**
* Inserts array (converted to XML node) in XML node
*
* @param array<string, mixed> $arrayNode
* @param SimpleRSSElement $xmlNode The XML parent node where this arrayNode should be attached
*/
function array_to_rss(array $arrayNode, SimpleRSSElement &$xmlNode): SimpleRSSElement
{
if (array_key_exists('elements', $arrayNode)) {
foreach ($arrayNode['elements'] as $childArrayNode) {
$childXmlNode = $xmlNode->addChild(
$childArrayNode['name'],
$childArrayNode['content'] ?? null,
$childArrayNode['namespace'] === []
? null
: current($childArrayNode['namespace'])
);
if (array_key_exists('attributes', $childArrayNode)) {
foreach (
$childArrayNode['attributes'] as $attributeKey => $attributeValue
) {
$childXmlNode->addAttribute($attributeKey, $attributeValue);
}
}
array_to_rss($childArrayNode, $childXmlNode);
}
}
return $xmlNode;
}
}

View file

@ -8,19 +8,19 @@ use App\Entities\EpisodeComment;
use App\Entities\Page;
use App\Entities\Podcast;
use App\Entities\Post;
use Melbahja\Seo\MetaTags;
use App\Libraries\HtmlHead;
use Melbahja\Seo\Schema;
use Melbahja\Seo\Schema\Thing;
use Modules\Fediverse\Entities\PreviewCard;
/**
* @copyright 2021 Ad Aures
* @copyright 2024 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (! function_exists('get_podcast_metatags')) {
function get_podcast_metatags(Podcast $podcast, string $page): string
if (! function_exists('set_podcast_metatags')) {
function set_podcast_metatags(Podcast $podcast, string $page): void
{
$category = '';
if ($podcast->category->parent_id !== null) {
@ -47,14 +47,15 @@ if (! function_exists('get_podcast_metatags')) {
'inLanguage' => $podcast->language_code,
'genre' => $category,
],
type: 'PodcastSeries'
)
type: 'PodcastSeries',
),
);
$metatags = new MetaTags();
/** @var HtmlHead $head */
$head = service('html_head');
$metatags
->title($podcast->title . ' (@' . $podcast->handle . ') • ' . lang('Podcast.' . $page))
$head
->title(sprintf('%s (@%s) • %s', $podcast->title, $podcast->handle, lang('Podcast.' . $page)))
->description(esc($podcast->description))
->image((string) $podcast->cover->og_url)
->canonical((string) current_url())
@ -62,24 +63,18 @@ if (! function_exists('get_podcast_metatags')) {
->og('image:height', (string) config('Images')->podcastCoverSizes['og']['height'])
->og('locale', $podcast->language_code)
->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('podcast-activity', esc($podcast->handle)),
]);
if ($podcast->payment_pointer) {
$metatags->meta('monetization', $podcast->payment_pointer);
}
return '<link type="application/rss+xml" rel="alternate" title="' . esc(
$podcast->title
) . '" href="' . $podcast->feed_url . '" />' . PHP_EOL . $metatags->__toString() . PHP_EOL . $schema->__toString();
])->appendRawContent('<link type="application/rss+xml" rel="alternate" title="' . esc(
$podcast->title,
) . '" href="' . $podcast->feed_url . '" />' . $schema);
}
}
if (! function_exists('get_episode_metatags')) {
function get_episode_metatags(Episode $episode): string
if (! function_exists('set_episode_metatags')) {
function set_episode_metatags(Episode $episode): void
{
$schema = new Schema(
new Thing(
@ -88,30 +83,31 @@ if (! function_exists('get_episode_metatags')) {
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ISO8601),
'datePublished' => $episode->published_at->format(DATE_ATOM),
'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration),
'associatedMedia' => new Thing(
props: [
'contentUrl' => $episode->audio_url,
],
type: 'MediaObject'
type: 'MediaObject',
),
'partOfSeries' => new Thing(
props: [
'name' => $episode->podcast->title,
'url' => $episode->podcast->link,
],
type: 'PodcastSeries'
type: 'PodcastSeries',
),
],
type: 'PodcastEpisode'
)
type: 'PodcastEpisode',
),
);
$metatags = new MetaTags();
/** @var HtmlHead $head */
$head = service('html_head');
$metatags
$head
->title($episode->title)
->description(esc($episode->description))
->image((string) $episode->cover->og_url, 'player')
@ -122,50 +118,45 @@ if (! function_exists('get_episode_metatags')) {
->og('locale', $episode->podcast->language_code)
->og('audio', $episode->audio_opengraph_url)
->og('audio:type', $episode->audio->file_mimetype)
->meta('article:published_time', $episode->published_at->format(DATE_ISO8601))
->meta('article:modified_time', $episode->updated_at->format(DATE_ISO8601))
->meta('article:published_time', $episode->published_at->format(DATE_ATOM))
->meta('article:modified_time', $episode->updated_at->format(DATE_ATOM))
->twitter('audio:partner', $episode->podcast->publisher ?? '')
->twitter('audio:artist_name', esc($episode->podcast->owner_name))
->twitter('player', $episode->getEmbedUrl('light'))
->twitter('player:width', (string) config('Embed')->width)
->twitter('player:height', (string) config('Embed')->height)
->push('link', [
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('episode', $episode->podcast->handle, $episode->slug),
]);
if ($episode->podcast->payment_pointer) {
$metatags->meta('monetization', $episode->podcast->payment_pointer);
}
return $metatags->__toString() . PHP_EOL . '<link rel="alternate" type="application/json+oembed" href="' . base_url(
route_to('episode-oembed-json', $episode->podcast->handle, $episode->slug)
) . '" title="' . esc(
$episode->title
) . ' oEmbed json" />' . PHP_EOL . '<link rel="alternate" type="text/xml+oembed" href="' . base_url(
route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug)
) . '" title="' . esc($episode->title) . ' oEmbed xml" />' . PHP_EOL . $schema->__toString();
'href' => $episode->link,
])
->appendRawContent('<link rel="alternate" type="application/json+oembed" href="' . base_url(
route_to('episode-oembed-json', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc(
$episode->title,
) . ' oEmbed json" />' . '<link rel="alternate" type="text/xml+oembed" href="' . base_url(
route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc($episode->title) . ' oEmbed xml" />' . $schema);
}
}
if (! function_exists('get_post_metatags')) {
function get_post_metatags(Post $post): string
if (! function_exists('set_post_metatags')) {
function set_post_metatags(Post $post): void
{
$socialMediaPosting = new Thing(
props: [
'@id' => url_to('post', esc($post->actor->username), $post->id),
'datePublished' => $post->published_at->format(DATE_ISO8601),
'datePublished' => $post->published_at->format(DATE_ATOM),
'author' => new Thing(
props: [
'name' => $post->actor->display_name,
'url' => $post->actor->uri,
],
type: 'Person'
type: 'Person',
),
'text' => $post->message,
],
type: 'SocialMediaPosting'
type: 'SocialMediaPosting',
);
if ($post->episode_id !== null) {
@ -177,10 +168,10 @@ if (! function_exists('get_post_metatags')) {
props: [
'name' => $post->episode->podcast->owner_name,
],
type: 'Person'
type: 'Person',
),
],
type: 'Audio'
type: 'Audio',
));
} elseif ($post->preview_card instanceof PreviewCard) {
$socialMediaPosting->__set('sharedContent', new Thing(
@ -191,17 +182,19 @@ if (! function_exists('get_post_metatags')) {
props: [
'name' => $post->preview_card->author_name,
],
type: 'Person'
type: 'Person',
),
],
type: 'WebPage'
type: 'WebPage',
));
}
$schema = new Schema($socialMediaPosting);
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Post.title', [
'actorDisplayName' => $post->actor->display_name,
]))
@ -209,18 +202,16 @@ if (! function_exists('get_post_metatags')) {
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('post', esc($post->actor->username), $post->id),
]);
return $metatags->__toString() . PHP_EOL . $schema->__toString();
])->appendRawContent((string) $schema);
}
}
if (! function_exists('get_episode_comment_metatags')) {
function get_episode_comment_metatags(EpisodeComment $episodeComment): string
if (! function_exists('set_episode_comment_metatags')) {
function set_episode_comment_metatags(EpisodeComment $episodeComment): void
{
$schema = new Schema(new Thing(
props: [
@ -228,24 +219,26 @@ if (! function_exists('get_episode_comment_metatags')) {
'episode-comment',
esc($episodeComment->actor->username),
$episodeComment->episode->slug,
$episodeComment->id
$episodeComment->id,
),
'datePublished' => $episodeComment->created_at->format(DATE_ISO8601),
'datePublished' => $episodeComment->created_at->format(DATE_ATOM),
'author' => new Thing(
props: [
'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri,
],
type: 'Person'
type: 'Person',
),
'text' => $episodeComment->message,
'upvoteCount' => $episodeComment->likes_count,
],
type: 'SocialMediaPosting'
type: 'SocialMediaPosting',
));
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Comment.title', [
'actorDisplayName' => $episodeComment->actor->display_name,
'episodeTitle' => $episodeComment->episode->title,
@ -254,26 +247,25 @@ if (! function_exists('get_episode_comment_metatags')) {
->image($episodeComment->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to(
'episode-comment',
$episodeComment->actor->username,
$episodeComment->episode->slug,
$episodeComment->id
$episodeComment->id,
),
]);
return $metatags->__toString() . PHP_EOL . $schema->__toString();
])->appendRawContent((string) $schema);
}
}
if (! function_exists('get_follow_metatags')) {
function get_follow_metatags(Actor $actor): string
if (! function_exists('set_follow_metatags')) {
function set_follow_metatags(Actor $actor): void
{
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Podcast.followTitle', [
'actorDisplayName' => $actor->display_name,
]))
@ -281,16 +273,15 @@ if (! function_exists('get_follow_metatags')) {
->image($actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}
if (! function_exists('get_remote_actions_metatags')) {
function get_remote_actions_metatags(Post $post, string $action): string
if (! function_exists('set_remote_actions_metatags')) {
function set_remote_actions_metatags(Post $post, string $action): void
{
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Fediverse.' . $action . '.title', [
'actorDisplayName' => $post->actor->display_name,
],))
@ -298,42 +289,40 @@ if (! function_exists('get_remote_actions_metatags')) {
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}
if (! function_exists('get_home_metatags')) {
function get_home_metatags(): string
if (! function_exists('set_home_metatags')) {
function set_home_metatags(): void
{
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(service('settings')->get('App.siteName'))
->description(esc(service('settings')->get('App.siteDescription')))
->image(get_site_icon_url('512'))
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}
if (! function_exists('get_page_metatags')) {
function get_page_metatags(Page $page): string
if (! function_exists('set_page_metatags')) {
function set_page_metatags(Page $page): void
{
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(
$page->title . service('settings')->get('App.siteTitleSeparator') . service(
'settings'
)->get('App.siteName')
'settings',
)->get('App.siteName'),
)
->description(esc(service('settings')->get('App.siteDescription')))
->image(get_site_icon_url('512'))
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}

View file

@ -1,14 +1,12 @@
+ br/***
+ ca/***
+ cs/***
+ de/***
+ en/***
+ es/***
+ fr/***
+ lt/***
+ nn-no/***
+ pl/***
+ de/***
+ pt-br/***
+ sr-latn/***
+ nn-no/***
+ es/***
+ zh-hans/***
+ ca/***
+ br/***
+ sr-latn/***
- **

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'title' => "Komentář {actorDisplayName} k {episodeTitle}",
'back_to_comments' => 'Zpět na komentáře',
'form' => [
'episode_message_placeholder' => 'Napište komentář…',
'reply_to_placeholder' => 'Odpovědět @{actorUsername}',
'submit' => 'Odeslat',
'submit_reply' => 'Odpovědět',
],
'likes' => '{numberOfLikes, plural,
one {# se líbí}
other {# se líbí}
}',
'replies' => '{numberOfReplies, plural,
one {# odpověď}
other {# odpovědi}
}',
'like' => 'Líbí se mi',
'reply' => 'Odpovědět',
'view_replies' => 'Zobrazit odpovědi ({numberOfReplies})',
'block_actor' => 'Blokovat uživatele @{actorUsername}',
'block_domain' => 'Blokovat doménu @{actorDomain}',
'delete' => 'Odstranit komentář',
];

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'yes' => 'Ano',
'no' => 'Ne',
'cancel' => 'Zrušit',
'optional' => 'Volitelné',
'close' => 'Zavřít',
'home' => 'Domů',
'explicit' => 'Explicitní',
'powered_by' => 'Běží na {castopod}',
'go_back' => 'Jít zpět',
'play_episode_button' => [
'play' => 'Přehrát',
'playing' => 'Přehrávání',
],
'read_more' => 'Číst více',
'read_less' => 'Číst méně',
'see_more' => 'Zobraz více',
'see_less' => 'Zobrazit méně',
'legal_notice' => 'Právní ustanovení',
];

View file

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'season' => 'Série: {{seasonNumber}}',
'season_abbr' => 'S{seasonNumber}',
'number' => 'Epizoda {episodeNumber}',
'number_abbr' => 'Ep. {episodeNumber}',
'season_episode' => 'Série {seasonNumber} epizoda {episodeNumber}',
'season_episode_abbr' => 'S{seasonNumber}:E{episodeNumber}',
'persons' => '{personsCount, plural,
one {# osoba}
other {# osoby}
}',
'persons_list' => 'Osoby',
'back_to_episodes' => 'Zpět na epizody {podcast}',
'comments' => 'Komentáře',
'activity' => 'Aktivita',
'chapters' => 'Kapitoly',
'transcript' => 'Přepis',
'description' => 'Popis epizody',
'number_of_comments' => '{numberOfComments, plural,
one {# komentář}
other {# komentáře}
}',
'all_podcast_episodes' => 'Všechny epizody podcastu',
'back_to_podcast' => 'Přejít zpět na podcast',
'preview' => [
'title' => 'Náhled',
'not_published' => 'Nezveřejněno',
'text' => '{publication_status, select,
published {Tato epizoda ještě není publikována.}
scheduled {Tato epizoda je naplánována na {publication_date}}
with_podcast {Tato epizoda bude zveřejněna současně s podcastem.}
other {Tato epizoda ještě není publikována.}
}',
'publish' => 'Publikovat',
'publish_edit' => 'Editovat publikaci',
],
'no_chapters' => 'Pro tuto epizodu nejsou k dispozici žádné kapitoly.',
'download_transcript' => 'Stáhnout přepis ({extension})',
'no_transcript' => 'Pro tuto epizodu není k dispozici žádný přepis.',
];

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'your_handle' => 'Vaše handle',
'your_handle_hint' => 'Zadejte @username@doména, ze které chcete působit.',
'follow' => [
'label' => 'Sledovat',
'title' => 'Sledovat {actorDisplayName}',
'subtitle' => 'Budete sledovat:',
'accountNotFound' => 'Účet nebyl nalezen.',
'remoteFollowNotAllowed' => 'Zdá se, že server účtu neumožňuje vzdálené sledování…',
'submit' => 'Pokračovat a sledovat',
],
'favourite' => [
'title' => "Oblíbený příspěvek {actorDisplayName}",
'subtitle' => 'Oblíbíte si:',
'submit' => 'Pokračovat a oblíbit',
],
'reblog' => [
'title' => "Sdílet {actorDisplayName}příspěvek",
'subtitle' => 'Budete sdílet:',
'submit' => 'Pokračovat ke sdílení',
],
'reply' => [
'title' => "Odpovědět na příspěvek {actorDisplayName}",
'subtitle' => 'Chystáte se odpovědět:',
'submit' => 'Pokračovat k odpovědi',
],
];

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'all_podcasts' => 'Všechny podcasty',
'sort_by' => 'Seřadit podle',
'sort_options' => [
'activity' => 'Poslední aktivita',
'created_desc' => 'Od nejnovějších',
'created_asc' => 'Od nejstarších',
],
'no_podcast' => 'Nebyly nalezeny žádné podcasty',
];

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'back_to_home' => 'Zpátky domů',
'map' => [
'title' => 'Mapa',
'description' => 'Objevte epizody podcastu na {siteName} , které jsou umístěny na mapě! Cestujte přes mapu a poslouchejte epizody, které hovoří o konkrétních místech.',
],
];

View file

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'feed' => 'RSS Podcast kanál',
'season' => 'Série: {seasonNumber}',
'list_of_episodes_year' => '{year} epizody ({episodeCount})',
'list_of_episodes_season' =>
'Epizody ({episodeCount}) série {seasonNumber}',
'no_episode' => 'Nebyla nalezena žádná epizoda',
'follow' => 'Sledovat',
'followTitle' => 'Sledujte {actorDisplayName} na fediverse!',
'followers' => '{numberOfFollowers, plural,
one {# sledující}
other {# sledující}
}',
'posts' => '{numberOfPosts, plural,
one {# příspěvek}
other {# příspěvky}
}',
'links' => 'Odkazy',
'activity' => 'Aktivita',
'episodes' => 'Epizody',
'episodes_title' => 'Epizody {podcastTitle}',
'about' => 'Informace',
'stats' => [
'title' => 'Statistiky',
'number_of_seasons' => '{0, plural,
one {# série}
other {# série}
}',
'number_of_episodes' => '{0, plural,
one {# epizoda}
other {# epizody}
}',
'first_published_at' => 'První epizoda zveřejněna na {0, date, medium}',
],
'sponsor' => 'Sponzorovat',
'funding_links' => 'Odkazy na financování pro {podcastTitle}',
'find_on' => 'Najít {podcastTitle} na',
'listen_on' => 'Poslouchat na',
'persons' => '{personsCount, plural,
one {# osoba}
other {# osoby}
}',
'persons_list' => 'Osoby',
'castopod_website' => 'Castopod (webová stránka)',
];

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'title' => "Příspěvek {actorDisplayName}",
'back_to_actor_posts' => 'Zpět na příspěvky {actor}',
'actor_shared' => '{actor} sdílen(a)',
'reply_to' => 'Odpovědět @{actorUsername}',
'form' => [
'message_placeholder' => 'Napsat zprávu…',
'episode_message_placeholder' => 'Napsat zprávu pro epizodu…',
'episode_url_placeholder' => 'URL epizody',
'reply_to_placeholder' => 'Odpovědět @{actorUsername}',
'submit' => 'Odeslat',
'submit_reply' => 'Odpovědět',
],
'favourites' => '{numberOfFavourites, plural,
one {# oblíbil}
other {# oblíbili}
}',
'reblogs' => '{numberOfReblogs, plural,
one {# sdílení}
other {# sdílení}
}',
'replies' => '{numberOfReplies, plural,
one {# odpověď}
other {# odpovědi}
}',
'expand' => 'Rozbalit příspěvek',
'block_actor' => 'Blokovat uživatele @{actorUsername}',
'block_domain' => 'Blokovat doménu @{actorDomain}',
'delete' => 'Smazat příspěvek',
];

View file

@ -9,7 +9,7 @@ declare(strict_types=1);
*/
return [
'your_handle' => 'Your handle',
'your_handle' => 'Your Fediverse handle',
'your_handle_hint' => 'Enter the @username@domain you want to act from.',
'follow' => [
'label' => 'Follow',

View file

@ -17,9 +17,9 @@ return [
'no_episode' => 'No episode found!',
'follow' => 'Follow',
'followTitle' => 'Follow {actorDisplayName} on the fediverse!',
'followers' => '{numberOfFollowers, plural,
one {# follower}
other {# followers}
'fediverseFollowers' => '{numberOfFollowers, plural,
one {# Fediverse follower}
other {# Fediverse followers}
}',
'posts' => '{numberOfPosts, plural,
one {# post}
@ -42,7 +42,7 @@ return [
}',
'first_published_at' => 'First episode published on {0, date, medium}',
],
'sponsor' => 'Sponsor',
'funding' => 'Funding',
'funding_links' => 'Funding links for {podcastTitle}',
'find_on' => 'Find {podcastTitle} on',
'listen_on' => 'Listen on',
@ -51,5 +51,5 @@ return [
other {# persons}
}',
'persons_list' => 'Persons',
'castopod_website' => 'Castopod (website)',
'links_mainpage' => 'Podcast (main page)',
];

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'title' => "{actorDisplayName} pakomentavo „{episodeTitle}“",
'back_to_comments' => 'Grįžti į komentarus',
'form' => [
'episode_message_placeholder' => 'Parašyti komentarą…',
'reply_to_placeholder' => 'Atsakyti @{actorUsername}',
'submit' => 'Siųsti',
'submit_reply' => 'Atsakyti',
],
'likes' => '{numberOfLikes, plural,
one {# patiktukas}
few {# patiktukai}
other {# patiktukų}
}',
'replies' => '{numberOfReplies, plural,
one {# atsakymas}
few {# atsakymai}
other {# atsakymų}
}',
'like' => 'Patinka',
'reply' => 'Atsakyti',
'view_replies' => 'Rodyti atsakymus ({numberOfReplies})',
'block_actor' => 'Blokuoti naudotoją @{actorUsername}',
'block_domain' => 'Blokuoti domeną @{actorDomain}',
'delete' => 'Šalinti komentarą',
];

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'yes' => 'Taip',
'no' => 'Ne',
'cancel' => 'Atsisakyti',
'optional' => 'Neprivaloma',
'close' => 'Užverti',
'home' => 'Pradžia',
'explicit' => 'Atviras',
'powered_by' => 'Veikia {castopod} pagrindu',
'go_back' => 'Grįžti',
'play_episode_button' => [
'play' => 'Leisti',
'playing' => 'Leidžiama',
],
'read_more' => 'Išsamiau',
'read_less' => 'Glausčiau',
'see_more' => 'Išsamiau',
'see_less' => 'Glausčiau',
'legal_notice' => 'Teisinė informacija',
];

View file

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'season' => '{seasonNumber} sezonas',
'season_abbr' => 'S{seasonNumber}',
'number' => '{episodeNumber} epizodas',
'number_abbr' => '{episodeNumber} ep.',
'season_episode' => '{seasonNumber} sezono {episodeNumber} epizodas',
'season_episode_abbr' => 'S{seasonNumber}:E{episodeNumber}',
'persons' => '{personsCount, plural,
one {# asmuo}
few {# asmenys}
other {# asmenų}
}',
'persons_list' => 'Asmenys',
'back_to_episodes' => 'Grįžti į „{podcast}“ epizodų sąrašą',
'comments' => 'Komentarai',
'activity' => 'Veikla',
'chapters' => 'Skyreliai',
'transcript' => 'Nuorašas',
'description' => 'Epizodo aprašymas',
'number_of_comments' => '{numberOfComments, plural,
one {# komentaras}
few {# komentarai}
other {# komentarų}
}',
'all_podcast_episodes' => 'Visi tinklalaidės epizodai',
'back_to_podcast' => 'Grįžti į tinklalaidę',
'preview' => [
'title' => 'Peržiūrėti',
'not_published' => 'Nepaskelbtas',
'text' => '{publication_status, select,
published {Šis epizodas dar nepaskelbtas.}
scheduled {Šį epizodą planuojama paskelbti {publication_date}.}
with_podcast {Šį epizodą planuojama paskelbti kartu su tinklalaide.}
other {Šis epizodas dar nepaskelbtas.}
}',
'publish' => 'Paskelbti',
'publish_edit' => 'Taisyti paskelbimą',
],
'no_chapters' => 'Šis epizodas neišskaidytas skyreliais.',
'download_transcript' => 'Parsisiųsti nuorašą ({extension})',
'no_transcript' => 'Šio epizodo nuorašas nepateiktas.',
];

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'your_handle' => 'Jūsų paskyros vardas',
'your_handle_hint' => 'Įrašykite naudotinos paskyros vardą @naudotojas@domenas formatu.',
'follow' => [
'label' => 'Sekti',
'title' => 'Sekti {actorDisplayName}',
'subtitle' => 'Ketinate sekti:',
'accountNotFound' => 'Paskyra nerasta.',
'remoteFollowNotAllowed' => 'Panašu, jog paskyros serveris neleidžia nuotolinių sekimo užklausų…',
'submit' => 'Inicijuoti sekimą',
],
'favourite' => [
'title' => "Pamėgti {actorDisplayName} įrašą",
'subtitle' => 'Ketinate pamėgti:',
'submit' => 'Inicijuoti pamėgimą',
],
'reblog' => [
'title' => "Pasidalinti {actorDisplayName} įrašu",
'subtitle' => 'Ketinate pasidalinti:',
'submit' => 'Inicijuoti pasidalijimą',
],
'reply' => [
'title' => "Atsakyti į {actorDisplayName} įrašą",
'subtitle' => 'Ketinate atsakyti į:',
'submit' => 'Inicijuoti atsakymą',
],
];

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'all_podcasts' => 'Visos tinklalaidės',
'sort_by' => 'Rikiuoti pagal',
'sort_options' => [
'activity' => 'Paskiausia veikla',
'created_desc' => 'Prad',
'created_asc' => 'Pirma seniausi',
],
'no_podcast' => 'Tinklalaidė nerasta',
];

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'back_to_home' => 'Grįžti į pradžią',
'map' => [
'title' => 'Žemėlapis',
'description' => 'Atraskite „{siteName}“ paskelbtus tinklalaidžių epizodus žemėlapyje! Keliaukite po žemėlapį ir klausykitės epizodų, kuriuose kalbama apie konkrečias vietoves.',
],
];

View file

@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'feed' => 'Tinklalaidės RSS sklaidos kanalas',
'season' => '{seasonNumber} sezonas',
'list_of_episodes_year' => '{year} metų epizodai ({episodeCount})',
'list_of_episodes_season' =>
'{seasonNumber} sezono epizodai ({episodeCount})',
'no_episode' => 'Epizodų nerasta!',
'follow' => 'Sekti',
'followTitle' => 'Sekti {actorDisplayName} Fedivisatoje!',
'followers' => '{numberOfFollowers, plural,
one {# sekėjas}
few {# sekėjai}
other {# sekėjų}
}',
'posts' => '{numberOfPosts, plural,
one {# įrašas}
few {# įrašai}
other {# įrašų}
}',
'links' => 'Nuorodos',
'activity' => 'Veikla',
'episodes' => 'Epizodai',
'episodes_title' => '„{podcastTitle}“ epizodai',
'about' => 'Apie',
'stats' => [
'title' => 'Statistika',
'number_of_seasons' => '{0, plural,
one {# sezonas}
few {# sezonai}
other {# sezonų}
}',
'number_of_episodes' => '{0, plural,
one {# epizodas}
few {# epizodai}
other {# epizodų}
}',
'first_published_at' => 'Pirmasis epizodas paskelbtas {0, date, medium}',
],
'sponsor' => 'Paremti',
'funding_links' => '„{podcastTitle}“ rėmimo nuorodos',
'find_on' => 'Raskite „{podcastTitle}“',
'listen_on' => 'Klausykitės',
'persons' => '{personsCount, plural,
one {# asmuo}
few {# asmenys}
other {# asmenų}
}',
'persons_list' => 'Asmenys',
'castopod_website' => 'Castopod (svetainė)',
];

View file

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'title' => "{actorDisplayName} įrašas",
'back_to_actor_posts' => 'Grįžti į {actor} įrašus',
'actor_shared' => '{actor} pasidalijo',
'reply_to' => 'Atsakyti @{actorUsername}',
'form' => [
'message_placeholder' => 'Parašykite žinutę…',
'episode_message_placeholder' => 'Parašykite žinutę šiam epizodui…',
'episode_url_placeholder' => 'Epizodo URL adresas',
'reply_to_placeholder' => 'Atsakyti @{actorUsername}',
'submit' => 'Siųsti',
'submit_reply' => 'Atsakyti',
],
'favourites' => '{numberOfFavourites, plural,
one {# pamėgimas}
few {# pamėgimai}
other {# pamėgimų}
}',
'reblogs' => '{numberOfReblogs, plural,
one {# pasidalijimas}
few {# pasidalijimai}
other {# pasidalijimų}
}',
'replies' => '{numberOfReplies, plural,
one {# atsakymas}
few {# atsakymai}
other {# atsakymų}
}',
'expand' => 'Išskleisti įrašą',
'block_actor' => 'Blokuoti naudotoją @{actorUsername}',
'block_domain' => 'Blokuoti domeną @{actorDomain}',
'delete' => 'Šalinti įrašą',
];

View file

@ -32,12 +32,18 @@ class Breadcrumb
$uri = '';
foreach (current_url(true)->getSegments() as $segment) {
$uri .= '/' . $segment;
$this->links[] = [
$link = [
'text' => is_numeric($segment)
? $segment
: lang('Breadcrumb.' . $segment),
'href' => base_url($uri),
];
if (is_numeric($segment)) {
$this->links[] = $link;
} else {
$this->links[$segment] = $link;
}
}
}
@ -46,20 +52,19 @@ class Breadcrumb
*
* Given a breadcrumb with numeric params, this function replaces them with the values provided in $newParams
*
* Example with `Home / podcasts / 1 / episodes / 1`
* Example with `Home / podcasts / 1 / episodes / 1 / foo`
*
* $newParams = [ 0 => 'foo', 1 => 'bar' ] replaceParams($newParams);
* $newParams = [ 0 => 'bar', 1 => 'baz', 'foo' => 'I Pity The Foo' ] replaceParams($newParams);
*
* The breadcrumb is now `Home / podcasts / foo / episodes / bar`
* The breadcrumb is now `Home / podcasts / foo / episodes / bar / I Pity The Foo`
*
* @param string[] $newParams
* @param array<string|int,string> $newParams
*/
public function replaceParams(array $newParams): void
{
foreach ($this->links as $key => $link) {
if (is_numeric($link['text'])) {
$this->links[$key]['text'] = $newParams[0];
array_shift($newParams);
foreach ($newParams as $key => $newValue) {
if (array_key_exists($key, $this->links)) {
$this->links[$key]['text'] = $newValue;
}
}
}
@ -67,7 +72,7 @@ class Breadcrumb
/**
* Renders the breadcrumb object as an accessible html breadcrumb nav
*/
public function render(string $class = null): string
public function render(?string $class = null): string
{
$listItems = '';
$keys = array_keys($this->links);

View file

@ -39,7 +39,7 @@ class CommentObject extends ObjectType
'episode-comment-replies',
esc($comment->actor->username),
$comment->episode->slug,
$comment->id
$comment->id,
);
$this->cc = [$comment->actor->followers_url];

188
app/Libraries/HtmlHead.php Normal file
View file

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use App\Controllers\WebmanifestController;
use Override;
use Stringable;
/**
* Inspired by https://github.com/melbahja/seo
*/
class HtmlHead implements Stringable
{
protected ?string $title = null;
/**
* @var array{name:string,value:string|null,attributes:array<string,string|null>}[]
*/
protected array $tags = [];
protected string $rawContent = '';
#[Override]
public function __toString(): string
{
helper('misc');
$this
->tag('meta', null, [
'charset' => 'UTF-8',
])
->meta('viewport', 'width=device-width, initial-scale=1.0')
->tag('link', null, [
'rel' => 'icon',
'type' => 'image/x-icon',
'href' => get_site_icon_url('ico'),
])
->tag('link', null, [
'rel' => 'apple-touch-icon',
'href' => get_site_icon_url('180'),
])
->tag('link', null, [
'rel' => 'manifest',
// @phpstan-ignore-next-line
'href' => isset($podcast) ? route_to('podcast-webmanifest', esc($podcast->handle)) : route_to(
'webmanifest',
),
])
->meta(
'theme-color',
WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'],
)
->tag('link', null, [
'rel' => 'stylesheet',
'type' => 'text/css',
'href' => route_to('themes-colors-css'),
])
->appendRawContent(<<<HTML
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/assets/sw.js');
});
}
</script>
HTML);
if ($this->title) {
$this->tag('title', esc($this->title));
}
if (url_is(route_to('admin') . '*') || url_is(base_url(config('Auth')->gateway) . '*')) {
// restricted admin and auth areas, do not index
$this->meta('robots', 'noindex');
} else {
// public website, set siteHead hook only there
service('plugins')
->siteHead($this);
}
$head = '<head>';
foreach ($this->tags as $tag) {
if ($tag['value'] === null) {
$head .= <<<HTML
<{$tag['name']}{$this->stringify_attributes($tag['attributes'])}/>
HTML;
} else {
$head .= <<<HTML
<{$tag['name']} {$this->stringify_attributes($tag['attributes'])}>{$tag['value']}</{$tag['name']}>
HTML;
}
}
$head .= $this->rawContent . '</head>';
// reset head for next render
$this->title = null;
$this->tags = [];
$this->rawContent = '';
return $head;
}
public function title(string $title): self
{
$this->title = $title;
return $this->meta('title', $title)
->og('title', $title)
->twitter('title', $title);
}
public function description(string $desc): self
{
return $this->meta('description', $desc)
->og('description', $desc)
->twitter('description', $desc);
}
public function image(string $url, string $card = 'summary_large_image'): self
{
return $this->og('image', $url)
->twitter('card', $card)
->twitter('image', $url);
}
public function canonical(string $url): self
{
return $this->tag('link', null, [
'rel' => 'canonical',
'href' => $url,
]);
}
public function twitter(string $name, string $value): self
{
$this->meta("twitter:{$name}", $value);
return $this;
}
/**
* @param array<string,string|null> $attributes
*/
public function tag(string $name, ?string $value = null, array $attributes = []): self
{
$this->tags[] = [
'name' => $name,
'value' => $value,
'attributes' => $attributes,
];
return $this;
}
public function meta(string $name, string $content): self
{
$this->tag('meta', null, [
'name' => $name,
'content' => $content,
]);
return $this;
}
public function og(string $name, string $content): self
{
$this->meta('og:' . $name, $content);
return $this;
}
public function appendRawContent(string $content): self
{
$this->rawContent .= $content;
return $this;
}
/**
* @param array<string, string|null> $attributes
*/
private function stringify_attributes(array $attributes): string
{
return stringify_attributes($attributes);
}
}

View file

@ -15,9 +15,11 @@ declare(strict_types=1);
namespace App\Libraries;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\Router\Exceptions\RedirectException;
use CodeIgniter\HTTP\Exceptions\RedirectException;
use CodeIgniter\Router\Exceptions\RouterException;
use CodeIgniter\Router\Router as CodeIgniterRouter;
use Config\Routing;
use Override;
class Router extends CodeIgniterRouter
{
@ -29,6 +31,7 @@ class Router extends CodeIgniterRouter
*
* @return boolean Whether the route was matched or not.
*/
#[Override]
protected function checkRoutes(string $uri): bool
{
$routes = $this->collection->getRoutes($this->collection->getHTTPVerb());
@ -66,7 +69,7 @@ class Router extends CodeIgniterRouter
throw new RedirectException(
preg_replace('#^' . $routeKey . '$#u', (string) $redirectTo, $uri),
$this->collection->getRedirectCode($routeKey)
$this->collection->getRedirectCode($routeKey),
);
}
@ -76,7 +79,7 @@ class Router extends CodeIgniterRouter
preg_match(
'#^' . str_replace('{locale}', '(?<locale>[^/]+)', $matchedKey) . '$#u',
$uri,
$matched
$matched,
);
if ($this->collection->shouldUseSupportedLocalesOnly()
@ -179,24 +182,50 @@ class Router extends CodeIgniterRouter
return true;
}
[$controller] = explode('::', (string) $handler);
if (str_contains((string) $handler, '::')) {
[$controller, $methodAndParams] = explode('::', (string) $handler);
} else {
$controller = $handler;
$methodAndParams = '';
}
// Checks `/` in controller name
if (str_contains($controller, '/')) {
if (str_contains((string) $controller, '/')) {
throw RouterException::forInvalidControllerName($handler);
}
if (str_contains((string) $handler, '$') && str_contains($routeKey, '(')) {
// Checks dynamic controller
if (str_contains($controller, '$')) {
if (str_contains((string) $controller, '$')) {
throw RouterException::forDynamicController($handler);
}
// Using back-references
$handler = preg_replace('#^' . $routeKey . '$#u', (string) $handler, $uri);
if (config(Routing::class)->multipleSegmentsOneParam === false) {
// Using back-references
$segments = explode(
'/',
(string) preg_replace('#\A' . $routeKey . '\z#u', (string) $handler, $uri),
);
} else {
if (str_contains($methodAndParams, '/')) {
[$method, $handlerParams] = explode('/', $methodAndParams, 2);
$params = explode('/', $handlerParams);
$handlerSegments = array_merge([$controller . '::' . $method], $params);
} else {
$handlerSegments = [$handler];
}
$segments = [];
foreach ($handlerSegments as $segment) {
$segments[] = $this->replaceBackReferences($segment, $matches);
}
}
} else {
$segments = explode('/', (string) $handler);
}
$this->setRequest(explode('/', (string) $handler));
$this->setRequest($segments);
$this->setMatchedRoute($matchedKey, $handler);

View file

@ -11,10 +11,34 @@ declare(strict_types=1);
namespace App\Libraries;
use DOMDocument;
use Override;
use SimpleXMLElement;
class SimpleRSSElement extends SimpleXMLElement
class RssFeed extends SimpleXMLElement
{
public const ATOM_NS = 'atom';
public const ATOM_NAMESPACE = 'http://www.w3.org/2005/Atom';
public const ITUNES_NS = 'itunes';
public const ITUNES_NAMESPACE = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
public const PODCAST_NS = 'podcast';
public const PODCAST_NAMESPACE = 'https://podcastindex.org/namespace/1.0';
public function __construct(string $contents = '')
{
parent::__construct(sprintf(
"<?xml version='1.0' encoding='utf-8'?><rss version='2.0' xmlns:atom='%s' xmlns:itunes='%s' xmlns:podcast='%s' xmlns:content='http://purl.org/rss/1.0/modules/content/'>%s</rss>",
$this::ATOM_NAMESPACE,
$this::ITUNES_NAMESPACE,
$this::PODCAST_NAMESPACE,
$contents,
));
}
/**
* Adds a child with $value inside CDATA
*
@ -47,6 +71,7 @@ class SimpleRSSElement extends SimpleXMLElement
*
* @return static The addChild method returns a SimpleXMLElement object representing the child added to the XML node.
*/
#[Override]
public function addChild($name, $value = null, $namespace = null, $escape = true): static
{
$newChild = parent::addChild($name, null, $namespace);
@ -61,4 +86,37 @@ class SimpleRSSElement extends SimpleXMLElement
return $newChild;
}
/**
* Add RssFeed code into a RssFeed
*
* adapted from: https://stackoverflow.com/a/23527002
*
* @param self|array<self> $nodes
*/
public function appendNodes(self|array $nodes): void
{
if (! is_array($nodes)) {
$nodes = [$nodes];
}
foreach ($nodes as $element) {
$namespaces = $element->getNamespaces();
$namespace = array_first($namespaces) ?? null;
if (trim((string) $element) === '') {
$simpleRSS = $this->addChild($element->getName(), null, $namespace);
} else {
$simpleRSS = $this->addChild($element->getName(), (string) $element, $namespace);
}
foreach ($element->children() as $child) {
$simpleRSS->appendNodes($child);
}
foreach ($element->attributes() as $name => $value) {
$simpleRSS->addAttribute($name, (string) $value);
}
}
}
}

View file

@ -4,26 +4,32 @@ declare(strict_types=1);
namespace ViewComponents;
class Component implements ComponentInterface
{
protected string $slot = '';
use Override;
protected string $class = '';
abstract class Component implements ComponentInterface
{
/**
* @var list<string>
*/
protected array $props = [];
/**
* @var array<string, string|'boolean'|'array'|'number'>
*/
protected array $casts = [];
protected ?string $slot = null;
/**
* @var array<string, string>
*/
protected array $attributes = [
'class' => '',
];
protected array $attributes = [];
/**
* @param array<string, string> $attributes
*/
public function __construct(array $attributes)
{
helper('viewcomponents');
// overwrite default attributes if set
$this->attributes = [...$this->attributes, ...$attributes];
@ -42,11 +48,42 @@ class Component implements ComponentInterface
if (is_callable([$this, $method])) {
$this->{$method}($value);
} else {
if (array_key_exists($name, $this->casts)) {
$value = match ($this->casts[$name]) {
'boolean' => $value === 'true',
'number' => (int) $value,
'array' => json_decode(htmlspecialchars_decode($value), true),
default => $value,
};
}
$this->{$name} = $value;
}
// remove from attributes
if (in_array($name, $this->props, true)) {
unset($this->attributes[$name]);
}
}
unset($this->attributes['slot']);
}
public function mergeClass(string $class): void
{
if (! array_key_exists('class', $this->attributes)) {
$this->attributes['class'] = $class;
} else {
$this->attributes['class'] .= ' ' . $class;
}
}
public function getStringifiedAttributes(): string
{
return stringify_attributes($this->attributes);
}
#[Override]
public function render(): string
{
return static::class . ': RENDER METHOD NOT IMPLEMENTED';

View file

@ -43,38 +43,38 @@ class ComponentRenderer
private function renderSelfClosingTags(string $output): string
{
// Pattern borrowed and adapted from Laravel's ComponentTagCompiler
// Should match any Component tags <Component />
// Should match any Component tags <x-Component />
$pattern = "/
<
\s*
(?<name>[A-Z][A-Za-z0-9\.]*?)
\s*
\\s*
x[-\\:](?<name>[\\w\\-\\:\\.]*)
\\s*
(?<attributes>
(?:
\s+
\\s+
(?:
(?:
\{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\}
\\{\\{\\s*\\\$attributes(?:[^}]+?)?\\s*\\}\\}
)
|
(?:
[\w\-:.@]+
[\\w\\-:.@]+
(
=
(?:
\\\"[^\\\"]*\\\"
|
\'[^\']*\'
\\'[^\\']*\\'
|
[^\'\\\"=<>]+
[^\\'\\\"=<>]+
)
)?
)
)
)*
\s*
\\s*
)
\/>
\\/>
/x";
/*
@ -96,8 +96,9 @@ class ComponentRenderer
private function renderPairedTags(string $output): string
{
$pattern = '/<\s*(?<name>[A-Z][A-Za-z0-9\.]*?)(?<attributes>(\s*[\w\-]+\s*=\s*(\'[^\']*\'|\"[^\"]*\"))+\s*)>(?<slot>.*)<\/\s*\1\s*>/uUsm';
ini_set('pcre.backtrack_limit', '-1');
// ini_set('pcre.backtrack_limit', '-1');
$pattern = '/<\s*x[-\:](?<name>[\w\-\:\.]*?)(?<attributes>(\s*[\w\-]+\s*=\s*(\'[^\']*\'|\"[^\"]*\"))+\s*)>(?<slot>.*)<\/\s*x-\1\s*>/uiUsm';
/*
$matches[0] = full tags matched and all of its content
$matches[name] = pascal cased tag name
@ -167,8 +168,6 @@ class ComponentRenderer
(
\"[^\"]+\"
|
\'[^\']+\'
|
\\\'[^\\\']+\\\'
|
[^\s>]+

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ViewComponents;
use CodeIgniter\View\ViewDecoratorInterface;
use Override;
/**
* Enables rendering of View Components into the views.
@ -15,6 +16,7 @@ class Decorator implements ViewDecoratorInterface
{
private static ?ComponentRenderer $components = null;
#[Override]
public static function decorate(string $html): string
{
$components = self::factory();

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
if (! function_exists('flatten_attributes')) {
/**
* Stringify attributes for use in HTML tags.
*
* Helper function used to convert a string, array, or object of attributes to a string.
*
* @param mixed $attributes string, array, object
*/
function flatten_attributes(mixed $attributes, bool $js = false): string
{
$atts = '';
if ($attributes === null) {
return $atts;
}
if (is_string($attributes)) {
return ' ' . $attributes;
}
$attributes = (array) $attributes;
foreach ($attributes as $key => $val) {
$atts .= ($js) ? $key . '=' . esc($val, 'js') . ',' : ' ' . $key . '="' . $val . '"';
}
return rtrim($atts, ',');
}
}

View file

@ -46,7 +46,7 @@ class Theme
/**
* Returns the path to the specified theme folder. If no theme is provided, will use the current theme.
*/
public static function path(string $theme = null): string
public static function path(?string $theme = null): string
{
if ($theme === null) {
$theme = static::current();

View file

@ -12,14 +12,16 @@ namespace App\Models;
use App\Entities\Actor;
use Modules\Fediverse\Models\ActorModel as FediverseActorModel;
use Override;
class ActorModel extends FediverseActorModel
{
/**
* @var string
* @var class-string<Actor>
*/
protected $returnType = Actor::class;
#[Override]
public function getActorById(int $id): ?Actor
{
return $this->find($id);

View file

@ -31,7 +31,7 @@ class CategoryModel extends Model
protected $allowedFields = ['parent_id', 'code', 'apple_category', 'google_category'];
/**
* @var string
* @var class-string<Category>
*/
protected $returnType = Category::class;
@ -65,12 +65,17 @@ class CategoryModel extends Model
$options = array_reduce(
$categories,
static function (array $result, Category $category): array {
$result[$category->id] = '';
$label = '';
if ($category->parent instanceof Category) {
$result[$category->id] = lang('Podcast.category_options.' . $category->parent->code) . ' ';
$label = lang('Podcast.category_options.' . $category->parent->code) . ' ';
}
$result[$category->id] .= lang('Podcast.category_options.' . $category->code);
$label .= lang('Podcast.category_options.' . $category->code);
$result[] = [
'value' => $category->id,
'label' => $label,
];
return $result;
},
[],

View file

@ -67,8 +67,8 @@ class ClipModel extends Model
public function __construct(
protected string $type = 'audio',
ConnectionInterface &$db = null,
ValidationInterface $validation = null
?ConnectionInterface &$db = null,
?ValidationInterface $validation = null,
) {
switch ($type) {
case 'audio':

View file

@ -21,7 +21,7 @@ class CreditModel extends Model
protected $table = 'credits';
/**
* @var string
* @var class-string<Credit>
*/
protected $returnType = Credit::class;
}

View file

@ -25,7 +25,7 @@ use Modules\Fediverse\Objects\TombstoneObject;
class EpisodeCommentModel extends UuidModel
{
/**
* @var string
* @var class-string<EpisodeComment>
*/
protected $returnType = EpisodeComment::class;
@ -86,11 +86,13 @@ class EpisodeCommentModel extends UuidModel
}
if ($comment->in_reply_to_id === null) {
(new EpisodeModel())->builder()
new EpisodeModel()
->builder()
->where('id', $comment->episode_id)
->increment('comments_count');
} else {
(new self())->builder()
new self()
->builder()
->where('id', service('uuid')->fromString($comment->in_reply_to_id)->getBytes())
->increment('replies_count');
}
@ -102,7 +104,7 @@ class EpisodeCommentModel extends UuidModel
'episode-comment',
esc($comment->actor->username),
$comment->episode->slug,
$comment->id
$comment->id,
);
$createActivity = new CreateActivity();
@ -180,7 +182,8 @@ class EpisodeCommentModel extends UuidModel
->where('id', $comment->episode_id)
->decrement('comments_count');
} else {
(new self())->builder()
new self()
->builder()
->where('id', service('uuid')->fromString($comment->in_reply_to_id)->getBytes())
->decrement('replies_count');
}
@ -211,7 +214,7 @@ class EpisodeCommentModel extends UuidModel
$postModel = new PostModel();
$episodePostsRepliesBuilder = $postModel->builder();
$episodePostsReplies = $episodePostsRepliesBuilder->select(
'id, uri, episode_id, actor_id, in_reply_to_id, message, message_html, favourites_count as likes_count, replies_count, published_at as created_at, created_by, is_private, 1 as is_from_post'
'id, uri, episode_id, actor_id, in_reply_to_id, message, message_html, favourites_count as likes_count, replies_count, published_at as created_at, created_by, is_private, 1 as is_from_post',
)
->whereIn('in_reply_to_id', static function (BaseBuilder $builder) use (&$episodeId): BaseBuilder {
return $builder->select('id')
@ -232,12 +235,12 @@ class EpisodeCommentModel extends UuidModel
/** @var BaseResult $allEpisodeComments */
$allEpisodeComments = $this->db->query(
$episodeComments . ' UNION ' . $episodePostsReplies . ' ORDER BY created_at ASC'
$episodeComments . ' UNION ' . $episodePostsReplies . ' ORDER BY created_at ASC',
);
return $this->convertUuidFieldsToStrings(
$allEpisodeComments->getCustomResultObject($this->tempReturnType),
$this->tempReturnType
$this->tempReturnType,
);
}

View file

@ -87,7 +87,6 @@ class EpisodeModel extends UuidModel
'location_name',
'location_geo',
'location_osm',
'custom_rss',
'is_published_on_hubs',
'downloads_count',
'posts_count',
@ -99,7 +98,7 @@ class EpisodeModel extends UuidModel
];
/**
* @var string
* @var class-string<Episode>
*/
protected $returnType = Episode::class;
@ -236,8 +235,8 @@ class EpisodeModel extends UuidModel
public function getPodcastEpisodes(
int $podcastId,
string $podcastType,
string $year = null,
string $season = null
?string $year = null,
?string $season = null,
): array {
$cacheName = implode(
'_',
@ -348,7 +347,7 @@ class EpisodeModel extends UuidModel
{
$result = $this->builder()
->select(
'COUNT(DISTINCT season_number) as number_of_seasons, COUNT(*) as number_of_episodes, MIN(published_at) as first_published_at'
'COUNT(DISTINCT season_number) as number_of_seasons, COUNT(*) as number_of_episodes, MIN(published_at) as first_published_at',
)
->where('podcast_id', $podcastId)
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
@ -369,13 +368,15 @@ class EpisodeModel extends UuidModel
public function resetCommentsCount(): int | false
{
$episodeCommentsCount = (new EpisodeCommentModel())->builder()
$episodeCommentsCount = new EpisodeCommentModel()
->builder()
->select('episode_id, COUNT(*) as `comments_count`')
->where('in_reply_to_id')
->groupBy('episode_id')
->getCompiledSelect();
$episodePostsRepliesCount = (new PostModel())->builder()
$episodePostsRepliesCount = new PostModel()
->builder()
->select('fediverse_posts.episode_id as episode_id, COUNT(*) as `comments_count`')
->join('fediverse_posts as fp', 'fediverse_posts.id = fp.in_reply_to_id')
->where('fediverse_posts.in_reply_to_id')
@ -385,13 +386,14 @@ class EpisodeModel extends UuidModel
/** @var BaseResult $query */
$query = $this->db->query(
'SELECT `episode_id` as `id`, SUM(`comments_count`) as `comments_count` FROM (' . $episodeCommentsCount . ' UNION ALL ' . $episodePostsRepliesCount . ') x GROUP BY `episode_id`'
'SELECT `episode_id` as `id`, SUM(`comments_count`) as `comments_count` FROM (' . $episodeCommentsCount . ' UNION ALL ' . $episodePostsRepliesCount . ') x GROUP BY `episode_id`',
);
$countsPerEpisodeId = $query->getResultArray();
if ($countsPerEpisodeId !== []) {
return (new self())->updateBatch($countsPerEpisodeId, 'id');
return new self()
->updateBatch($countsPerEpisodeId, 'id');
}
return 0;
@ -430,7 +432,8 @@ class EpisodeModel extends UuidModel
}
/** @var ?Episode $episode */
$episode = (new self())->find($episodeId);
$episode = new self()
->find($episodeId);
if (! $episode instanceof Episode) {
return $data;
@ -481,7 +484,7 @@ class EpisodeModel extends UuidModel
')
->select("{$podcastTable}.created_at AS podcast_created_at")
->select(
"{$podcastTable}.title as podcast_title, {$podcastTable}.handle as podcast_handle, {$podcastTable}.description_markdown as podcast_description_markdown"
"{$podcastTable}.title as podcast_title, {$podcastTable}.handle as podcast_handle, {$podcastTable}.description_markdown as podcast_description_markdown",
)
->join($podcastTable, "{$podcastTable} on {$podcastTable}.id = {$episodeTable}.podcast_id")
->where('
@ -490,7 +493,7 @@ class EpisodeModel extends UuidModel
. 'OR' .
$podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query)
. ')
');
', );
return $this->builder;
}
@ -524,7 +527,8 @@ class EpisodeModel extends UuidModel
}
/** @var ?Episode $episode */
$episode = (new self())->find($episodeId);
$episode = new self()
->find($episodeId);
if (! $episode instanceof Episode) {
return $data;

View file

@ -31,7 +31,7 @@ class LanguageModel extends Model
protected $allowedFields = ['code', 'native_name'];
/**
* @var string
* @var class-string<Language>
*/
protected $returnType = Language::class;
@ -56,7 +56,10 @@ class LanguageModel extends Model
$options = array_reduce(
$languages,
static function (array $result, Language $language): array {
$result[$language->code] = $language->native_name;
$result[] = [
'value' => $language->code,
'label' => $language->native_name,
];
return $result;
},
[],

View file

@ -36,7 +36,7 @@ class LikeModel extends UuidModel
protected $allowedFields = ['actor_id', 'comment_id'];
/**
* @var string
* @var class-string<Like>
*/
protected $returnType = Like::class;
@ -56,7 +56,8 @@ class LikeModel extends UuidModel
'comment_id' => $comment->id,
]);
(new EpisodeCommentModel())->builder()
new EpisodeCommentModel()
->builder()
->where('id', service('uuid')->fromString($comment->id)->getBytes())
->increment('likes_count');
@ -91,7 +92,8 @@ class LikeModel extends UuidModel
{
$this->db->transStart();
(new EpisodeCommentModel())->builder()
new EpisodeCommentModel()
->builder()
->where('id', service('uuid') ->fromString($comment->id) ->getBytes())
->decrement('likes_count');

View file

@ -31,7 +31,7 @@ class PageModel extends Model
protected $allowedFields = ['id', 'title', 'slug', 'content_markdown', 'content_html'];
/**
* @var string
* @var class-string<Page>
*/
protected $returnType = Page::class;

View file

@ -39,7 +39,7 @@ class PersonModel extends Model
];
/**
* @var string
* @var class-string<Person>
*/
protected $returnType = Person::class;
@ -146,7 +146,10 @@ class PersonModel extends Model
->orderBy('`full_name`', 'ASC')
->findAll(),
static function (array $result, Person $person): array {
$result[$person->id] = $person->full_name;
$result[] = [
'value' => $person->id,
'label' => $person->full_name,
];
return $result;
},
[],
@ -174,9 +177,10 @@ class PersonModel extends Model
if (! ($options = cache($cacheName))) {
foreach ($personsTaxonomy as $group_key => $group) {
foreach ($group['roles'] as $role_key => $role) {
$options[
"{$group_key},{$role_key}"
] = "{$group['label']} {$role['label']}";
$options[] = [
'value' => sprintf('%s,%s', $group_key, $role_key),
'label' => sprintf('%s %s', $group['label'], $role['label']),
];
}
}
@ -211,7 +215,7 @@ class PersonModel extends Model
if (! ($found = cache($cacheName))) {
$this->builder()
->select(
'persons.*, episodes_persons.podcast_id as podcast_id, episodes_persons.episode_id as episode_id'
'persons.*, episodes_persons.podcast_id as podcast_id, episodes_persons.episode_id as episode_id',
)
->distinct()
->join('episodes_persons', 'persons.id = episodes_persons.person_id')
@ -253,7 +257,7 @@ class PersonModel extends Model
int $episodeId,
int $personId,
string $groupSlug,
string $roleSlug
string $roleSlug,
): bool {
return $this->db->table('episodes_persons')
->insert([
@ -293,9 +297,10 @@ class PersonModel extends Model
cache()
->delete("podcast#{$podcastId}_persons");
(new PodcastModel())->clearCache([
'id' => $podcastId,
]);
new PodcastModel()
->clearCache([
'id' => $podcastId,
]);
$data = [];
foreach ($personIds as $personId) {
@ -335,9 +340,10 @@ class PersonModel extends Model
cache()->deleteMatching("podcast#{$podcastId}_person#{$personId}*");
cache()
->delete("podcast#{$podcastId}_persons");
(new PodcastModel())->clearCache([
'id' => $podcastId,
]);
new PodcastModel()
->clearCache([
'id' => $podcastId,
]);
return $this->db->table('podcasts_persons')
->delete([
@ -359,9 +365,10 @@ class PersonModel extends Model
if ($personIds !== []) {
cache()
->delete("podcast#{$podcastId}_episode#{$episodeId}_persons");
(new EpisodeModel())->clearCache([
'id' => $episodeId,
]);
new EpisodeModel()
->clearCache([
'id' => $episodeId,
]);
$data = [];
foreach ($personIds as $personId) {
@ -400,9 +407,10 @@ class PersonModel extends Model
cache()->deleteMatching("podcast#{$podcastId}_episode#{$episodeId}_person#{$personId}*");
cache()
->delete("podcast#{$podcastId}_episode#{$episodeId}_persons");
(new EpisodeModel())->clearCache([
'id' => $episodeId,
]);
new EpisodeModel()
->clearCache([
'id' => $episodeId,
]);
return $this->db->table('episodes_persons')
->delete([

View file

@ -38,8 +38,6 @@ class PodcastModel extends Model
'handle',
'description_markdown',
'description_html',
'episode_description_footer_markdown',
'episode_description_footer_html',
'cover_id',
'banner_id',
'language_code',
@ -47,10 +45,8 @@ class PodcastModel extends Model
'parental_advisory',
'owner_name',
'owner_email',
'is_owner_email_removed_from_feed',
'publisher',
'type',
'medium',
'copyright',
'imported_feed_url',
'new_feed_url',
@ -60,13 +56,7 @@ class PodcastModel extends Model
'location_name',
'location_geo',
'location_osm',
'verify_txt',
'payment_pointer',
'custom_rss',
'is_published_on_hubs',
'partner_id',
'partner_link_url',
'partner_image_url',
'is_premium_by_default',
'published_at',
'created_by',
@ -74,7 +64,7 @@ class PodcastModel extends Model
];
/**
* @var string
* @var class-string<Podcast>
*/
protected $returnType = Podcast::class;
@ -173,7 +163,7 @@ class PodcastModel extends Model
/**
* @return Podcast[]
*/
public function getAllPodcasts(string $orderBy = null): array
public function getAllPodcasts(?string $orderBy = null): array
{
$prefix = $this->db->getPrefix();
@ -185,7 +175,7 @@ class PodcastModel extends Model
->where(
'`' . $prefix . 'fediverse_posts`.`published_at` <= UTC_TIMESTAMP()',
null,
false
false,
)->orWhere('fediverse_posts.published_at')
->groupEnd()
->groupBy('podcasts.actor_id')
@ -319,7 +309,8 @@ class PodcastModel extends Model
];
}
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode($podcastId);
$secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($podcastId);
cache()
->save($cacheName, $defaultQuery, $secondsToNextUnpublishedEpisode ?: DECADE);
@ -335,7 +326,8 @@ class PodcastModel extends Model
*/
public function clearCache(array $data): array
{
$podcast = (new self())->find((int) (is_array($data['id']) ? $data['id'][0] : $data['id']));
$podcast = new self()
->find((int) (is_array($data['id']) ? $data['id'][0] : $data['id']));
// delete cache for users' podcasts
cache()
@ -399,21 +391,22 @@ class PodcastModel extends Model
$domain =
$url->getHost() . ($url->getPort() ? ':' . $url->getPort() : '');
$actorId = (new ActorModel())->insert(
[
'uri' => url_to('podcast-activity', $username),
'username' => $username,
'domain' => $domain,
'private_key' => $privatekey,
'public_key' => $publickey,
'display_name' => $data['data']['title'],
'summary' => $data['data']['description_html'],
'inbox_url' => url_to('inbox', $username),
'outbox_url' => url_to('outbox', $username),
'followers_url' => url_to('followers', $username),
],
true,
);
$actorId = new ActorModel()
->insert(
[
'uri' => url_to('podcast-activity', $username),
'username' => $username,
'domain' => $domain,
'private_key' => $privatekey,
'public_key' => $publickey,
'display_name' => $data['data']['title'],
'summary' => $data['data']['description_html'],
'inbox_url' => url_to('inbox', $username),
'outbox_url' => url_to('outbox', $username),
'followers_url' => url_to('followers', $username),
],
true,
);
$data['data']['actor_id'] = $actorId;
@ -427,10 +420,12 @@ class PodcastModel extends Model
*/
protected function setActorAvatar(array $data): array
{
$podcast = (new self())->find((int) (is_array($data['id']) ? $data['id'][0] : $data['id']));
$podcast = new self()
->find((int) (is_array($data['id']) ? $data['id'][0] : $data['id']));
if ($podcast instanceof Podcast) {
$podcastActor = (new ActorModel())->find($podcast->actor_id);
$podcastActor = new ActorModel()
->find($podcast->actor_id);
if (! $podcastActor instanceof Actor) {
return $data;
@ -439,7 +434,8 @@ class PodcastModel extends Model
$podcastActor->avatar_image_url = $podcast->cover->federation_url;
$podcastActor->avatar_image_mimetype = $podcast->cover->federation_mimetype;
(new ActorModel())->update($podcast->actor_id, $podcastActor);
new ActorModel()
->update($podcast->actor_id, $podcastActor);
}
return $data;
@ -452,7 +448,8 @@ class PodcastModel extends Model
*/
protected function updatePodcastActor(array $data): array
{
$podcast = (new self())->find((int) (is_array($data['id']) ? $data['id'][0] : $data['id']));
$podcast = new self()
->find((int) (is_array($data['id']) ? $data['id'][0] : $data['id']));
if ($podcast instanceof Podcast) {
$actorModel = new ActorModel();
@ -488,7 +485,7 @@ class PodcastModel extends Model
{
if (! array_key_exists(
'guid',
$data['data']
$data['data'],
) || $data['data']['guid'] === null || $data['data']['guid'] === '') {
$uuid = service('uuid');
$feedUrl = url_to('podcast-rss-feed', $data['data']['handle']);

View file

@ -16,7 +16,7 @@ use Modules\Fediverse\Models\PostModel as FediversePostModel;
class PostModel extends FediversePostModel
{
/**
* @var string
* @var class-string<Post>
*/
protected $returnType = Post::class;

View file

@ -11,13 +11,15 @@ declare(strict_types=1);
namespace App\Validation;
use CodeIgniter\Validation\FileRules as ValidationFileRules;
use Override;
class FileRules extends ValidationFileRules
{
/**
* Checks an uploaded file to verify that the dimensions are within a specified allowable dimension.
*/
public function min_dims(string $blank = null, string $params = ''): bool
#[Override]
public function min_dims(?string $blank = null, string $params = ''): bool
{
// Grab the file name off the top of the $params
// after we split it.
@ -59,7 +61,7 @@ class FileRules extends ValidationFileRules
/**
* Checks an uploaded image to verify that the ratio corresponds to the params
*/
public function is_image_ratio(string $blank = null, string $params = ''): bool
public function is_image_ratio(?string $blank = null, string $params = ''): bool
{
// Grab the file name off the top of the $params
// after we split it.
@ -99,7 +101,7 @@ class FileRules extends ValidationFileRules
/**
* Checks that an uploaded json file's content is valid
*/
public function is_json(string $blank = null, string $params = ''): bool
public function is_json(?string $blank = null, string $params = ''): bool
{
// Grab the file name off the top of the $params
// after we split it.

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Validation;
class OtherRules
{
/**
* Is a boolean (true or false)
*/
public function is_boolean(mixed $str = null): bool
{
return is_bool($str);
}
/**
* Is it an array?
*/
public function is_list(mixed $str = null): bool
{
return is_array($str);
}
public function is_string_or_list(mixed $str = null): bool
{
return is_string($str) || is_array($str);
}
}

View file

@ -4,22 +4,30 @@ declare(strict_types=1);
namespace App\Views\Components;
use Override;
use ViewComponents\Component;
class Alert extends Component
{
protected ?string $glyph = null;
protected array $props = ['glyph', 'title', 'variant'];
protected ?string $title = null;
protected string $glyph = '';
protected ?string $title = '';
protected array $attributes = [
'role' => 'alert',
];
/**
* @var 'default'|'success'|'danger'|'warning'
*/
protected string $variant = 'default';
#[Override]
public function render(): string
{
$variants = [
$variantData = match ($this->variant) {
'success' => [
'class' => 'text-pine-900 bg-pine-100 border-pine-300',
'glyph' => 'check-fill', // @icon("check-fill")
@ -32,30 +40,21 @@ class Alert extends Component
'class' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
'glyph' => 'alert-fill', // @icon("alert-fill")
],
'default' => [
default => [
'class' => 'text-blue-900 bg-blue-100 border-blue-300',
'glyph' => 'error-warning-fill', // @icon("error-warning-fill")
],
];
};
if (! array_key_exists($this->variant, $variants)) {
$this->variant = 'default';
}
$glyph = icon(($this->glyph ?? $variants[$this->variant]['glyph']), [
$glyph = icon(($this->glyph === '' ? $variantData['glyph'] : $this->glyph), [
'class' => 'flex-shrink-0 mr-2 text-lg',
]);
$title = $this->title === null ? '' : '<div class="font-semibold">' . $this->title . '</div>';
$class = 'inline-flex w-full p-2 text-sm border rounded ' . $variants[$this->variant]['class'] . ' ' . $this->class;
unset($this->attributes['slot']);
unset($this->attributes['variant']);
unset($this->attributes['class']);
unset($this->attributes['glyph']);
$attributes = stringify_attributes($this->attributes);
$title = $this->title === '' ? '' : '<div class="font-semibold">' . $this->title . '</div>';
$this->mergeClass('inline-flex w-full p-2 text-sm border rounded ');
$this->mergeClass($variantData['class']);
return <<<HTML
<div class="{$class}" role="alert" {$attributes}>{$glyph}<div>{$title}<p>{$this->slot}</p></div></div>
<div {$this->getStringifiedAttributes()}>{$glyph}<div>{$title}<p>{$this->slot}</p></div></div>
HTML;
}
}

View file

@ -4,14 +4,25 @@ declare(strict_types=1);
namespace App\Views\Components;
use Override;
use ViewComponents\Component;
class Button extends Component
{
protected array $props = ['uri', 'variant', 'size', 'iconLeft', 'iconRight', 'isSquared', 'isExternal'];
protected array $casts = [
'isSquared' => 'boolean',
'isExternal' => 'boolean',
];
protected string $uri = '';
protected string $variant = 'default';
/**
* @var 'small'|'base'|'large'
*/
protected string $size = 'base';
protected string $iconLeft = '';
@ -20,65 +31,54 @@ class Button extends Component
protected bool $isSquared = false;
public function setIsSquared(string $value): void
{
$this->isSquared = $value === 'true';
}
protected bool $isExternal = false;
#[Override]
public function render(): string
{
$baseClass =
'gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold rounded-full focus:ring-accent';
$this->mergeClass('shadow gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold rounded-full');
$variantClass = [
'default' => 'shadow-sm text-black bg-gray-300 hover:bg-gray-400',
'primary' => 'shadow-sm text-accent-contrast bg-accent-base hover:bg-accent-hover',
'secondary' => 'shadow-sm border-2 border-accent-base text-accent-base bg-white hover:border-accent-hover hover:text-accent-hover',
'success' => 'shadow-sm text-white bg-pine-500 hover:bg-pine-800',
'danger' => 'shadow-sm text-white bg-red-600 hover:bg-red-700',
'warning' => 'shadow-sm text-black bg-yellow-500 hover:bg-yellow-600',
'info' => 'shadow-sm text-white bg-blue-500 hover:bg-blue-600',
'disabled' => 'shadow-sm text-black bg-gray-300 cursor-not-allowed',
];
$variantClass = match ($this->variant) {
'primary' => 'text-accent-contrast bg-accent-base hover:bg-accent-hover',
'secondary' => 'ring-2 ring-accent-base ring-inset text-accent-base bg-white hover:border-accent-hover hover:text-accent-hover hover:ring-accent-hover',
'danger' => 'bg-red-50 ring-2 ring-red-700 ring-inset text-red-700 hover:ring-red-800 hover:text-red-800',
'warning' => 'bg-yellow-50 ring-2 ring-yellow-700 ring-inset text-yellow-700 hover:ring-yellow-800 hover:text-yellow-800',
'info' => 'bg-blue-50 ring-2 ring-blue-700 ring-inset text-blue-700 hover:ring-blue-800 hover:text-blue-800',
'disabled' => 'text-black bg-gray-300 cursor-not-allowed',
default => 'text-black bg-gray-50 hover:bg-gray-200',
};
$sizeClass = [
$sizeClass = match ($this->size) {
'small' => 'text-xs leading-6',
'base' => 'text-sm leading-5',
'large' => 'text-base leading-6',
];
default => 'text-sm leading-5',
};
$iconSize = [
$iconSizeClass = match ($this->size) {
'small' => 'text-sm',
'base' => 'text-lg',
'large' => 'text-2xl',
];
default => 'text-lg',
};
$basePaddings = [
$basePaddings = match ($this->size) {
'small' => 'px-3 py-1',
'base' => 'px-3 py-2',
'large' => 'px-4 py-2',
];
default => 'px-3 py-2',
};
$squaredPaddings = [
$squaredPaddings = match ($this->size) {
'small' => 'p-1',
'base' => 'p-2',
'large' => 'p-3',
];
default => 'p-2',
};
$buttonClass =
$baseClass .
' ' .
($this->isSquared
? $squaredPaddings[$this->size]
: $basePaddings[$this->size]) .
' ' .
$sizeClass[$this->size] .
' ' .
$variantClass[$this->variant];
$this->mergeClass($variantClass);
$this->mergeClass($sizeClass);
if (array_key_exists('class', $this->attributes)) {
$buttonClass .= ' ' . $this->attributes['class'];
unset($this->attributes['class']);
if ($this->isSquared) {
$this->mergeClass($squaredPaddings);
} else {
$this->mergeClass($basePaddings);
}
if ($this->iconLeft !== '' || $this->iconRight !== '') {
@ -87,41 +87,30 @@ class Button extends Component
if ($this->iconLeft !== '') {
$this->slot = icon($this->iconLeft, [
'class' => 'opacity-75 ' . $iconSize[$this->size],
'class' => 'opacity-75 ' . $iconSizeClass,
]) . $this->slot;
}
if ($this->iconRight !== '') {
$this->slot .= icon($this->iconRight, [
'class' => 'opacity-75 ' . $iconSize[$this->size],
'class' => 'opacity-75 ' . $iconSizeClass,
]);
}
unset($this->attributes['slot']);
unset($this->attributes['variant']);
unset($this->attributes['size']);
unset($this->attributes['iconLeft']);
unset($this->attributes['iconRight']);
unset($this->attributes['isSquared']);
unset($this->attributes['uri']);
unset($this->attributes['label']);
if ($this->uri !== '') {
$tagName = 'a';
$defaultButtonAttributes = [
'href' => $this->uri,
];
$this->attributes['href'] = $this->uri;
if ($this->isExternal) {
$this->attributes['target'] = '_blank';
$this->attributes['rel'] = 'noopener noreferrer';
}
} else {
$tagName = 'button';
$defaultButtonAttributes = [
'type' => 'button',
];
$this->attributes['type'] ??= 'button';
}
$attributes = stringify_attributes(array_merge($defaultButtonAttributes, $this->attributes));
return <<<HTML
<{$tagName} class="{$buttonClass}" {$attributes}>{$this->slot}</{$tagName}>
<{$tagName} {$this->getStringifiedAttributes()}>{$this->slot}</{$tagName}>
HTML;
}
}

View file

@ -4,18 +4,22 @@ declare(strict_types=1);
namespace App\Views\Components\Charts;
use Override;
use ViewComponents\Component;
class ChartsComponent extends Component
{
protected string $title = '';
protected array $props = ['title', 'subtitle', 'dataUrl', 'type'];
protected string $title;
protected string $subtitle = '';
protected string $dataUrl = '';
protected string $dataUrl;
protected string $type = '';
protected string $type;
#[Override]
public function render(): string
{
$subtitleBlock = '';
@ -23,8 +27,10 @@ class ChartsComponent extends Component
$subtitleBlock = '<p class="px-6 -mt-4 text-sm text-skin-muted">' . $this->subtitle . '</p>';
}
$this->mergeClass('bg-elevated border-3 rounded-xl border-subtle');
return <<<HTML
<div class="bg-elevated border-3 rounded-xl border-subtle {$this->class}">
<div {$this->getStringifiedAttributes()}>
<h2 class="px-6 py-4 text-xl">{$this->title}</h2>
{$subtitleBlock}
<div class="w-full h-[500px]" data-chart-type="{$this->type}" data-chart-url="{$this->dataUrl}"></div>

View file

@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Views\Components;
use Override;
use ViewComponents\Component;
class DashboardCard extends Component
{
protected ?string $href = null;
protected array $props = ['href', 'glyph', 'title', 'subtitle'];
protected string $href = '';
protected string $glyph;
@ -21,17 +24,18 @@ class DashboardCard extends Component
$this->subtitle = html_entity_decode($value);
}
#[Override]
public function render(): string
{
$glyph = (string) icon($this->glyph, [
'class' => 'flex-shrink-0 bg-base rounded-full w-8 h-8 p-2 text-accent-base',
]);
if ($this->href !== null && $this->href !== '') {
$chevronRight = (string) icon('arrow-right-s-fill');
if ($this->href !== '') {
$chevronRight = icon('arrow-right-s-fill');
$viewLang = lang('Common.view');
return <<<HTML
<a href="{$this->href}" class="flex items-center justify-between w-full gap-4 p-4 lg:max-w-sm lg:flex-col xl:flex-row bg-elevated focus:ring-accent rounded-xl border-3 border-subtle group">
<a href="{$this->href}" class="flex items-center justify-between w-full gap-4 p-4 lg:max-w-sm lg:flex-col xl:flex-row bg-elevated rounded-xl border-3 border-subtle group">
<div class="flex items-start">{$glyph}<div class="flex flex-col ml-2"><div class="flex items-center"><span class="text-xs font-semibold leading-loose tracking-wider uppercase">{$this->title}</span><div class="inline-flex items-center ml-4 transition -translate-x-full group-hover:translate-x-0 group-focus:translate-x-0"><span class="-ml-2 text-xs lowercase transition opacity-0 group-hover:opacity-100 group-focus:opacity-100">{$viewLang}</span>{$chevronRight}</div></div><p class="text-xs">{$this->subtitle}</p></div></div>
<div class="text-5xl font-bold">{$this->slot}</div>
</a>

View file

@ -5,27 +5,37 @@ declare(strict_types=1);
namespace App\Views\Components;
use Exception;
use Override;
use ViewComponents\Component;
class DropdownMenu extends Component
{
public string $id = '';
protected array $props = ['id', 'labelledby', 'placement', 'offsetX', 'offsetY', 'items'];
public string $labelledby;
protected array $casts = [
'offsetX' => 'number',
'offsetY' => 'number',
'items' => 'array',
];
public string $placement = 'bottom-end';
protected string $id;
public string $offsetX = '0';
protected string $labelledby;
public string $offsetY = '0';
protected string $placement = 'bottom-end';
public array $items = [];
protected int $offsetX = 0;
protected int $offsetY = 0;
protected array $items = [];
public function setItems(string $value): void
{
$this->items = json_decode(htmlspecialchars_decode($value), true);
}
#[Override]
public function render(): string
{
if ($this->items === []) {
@ -37,7 +47,7 @@ class DropdownMenu extends Component
switch ($item['type']) {
case 'link':
$menuItems .= anchor($item['uri'], $item['title'], [
'class' => 'px-4 py-1 hover:bg-highlight focus:ring-accent focus:ring-inset' . (array_key_exists('class', $item) ? ' ' . $item['class'] : ''),
'class' => 'inline-flex gap-x-1 items-center px-4 py-1 hover:bg-highlight' . (array_key_exists('class', $item) ? ' ' . $item['class'] : ''),
]);
break;
case 'html':
@ -51,14 +61,16 @@ class DropdownMenu extends Component
}
}
$this->mergeClass('absolute flex flex-col py-2 rounded-lg z-60 whitespace-nowrap text-skin-base border-contrast bg-elevated border-3');
$this->attributes['id'] = $this->id;
$this->attributes['aria-labelledby'] = $this->labelledby;
$this->attributes['data-dropdown'] = 'menu';
$this->attributes['data-dropdown-placement'] = $this->placement;
$this->attributes['data-dropdown-offset-x'] = $this->offsetX;
$this->attributes['data-dropdown-offset-y'] = $this->offsetY;
return <<<HTML
<nav id="{$this->id}"
class="absolute flex flex-col py-2 rounded-lg z-60 whitespace-nowrap text-skin-base border-contrast bg-elevated border-3"
aria-labelledby="{$this->labelledby}"
data-dropdown="menu"
data-dropdown-placement="{$this->placement}"
data-dropdown-offset-x="{$this->offsetX}"
data-dropdown-offset-y="{$this->offsetY}">{$menuItems}</nav>
<nav {$this->getStringifiedAttributes()}>{$menuItems}</nav>
HTML;
}
}

View file

@ -4,35 +4,59 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
use Override;
class Checkbox extends FormComponent
{
protected ?string $hint = null;
protected array $props = ['hint', 'helper'];
protected bool $isChecked = false;
protected array $casts = [
'isChecked' => 'boolean',
];
public function setIsChecked(string $value): void
{
$this->isChecked = $value === 'true';
}
protected string $hint = '';
protected string $helper = '';
#[Override]
public function render(): string
{
$attributes = [
'id' => $this->value,
'name' => $this->name,
'class' => 'form-checkbox bg-elevated text-accent-base border-contrast border-3 focus:ring-accent w-6 h-6',
];
$checkboxInput = form_checkbox(
$attributes,
[
'id' => $this->id,
'name' => $this->name,
'class' => 'form-checkbox bg-elevated text-accent-base border-contrast border-3 focus:ring-accent w-6 h-6 transition',
],
'yes',
old($this->name) ? old($this->name) === $this->value : $this->isChecked,
in_array($this->getValue(), ['yes', 'true', 'on', '1'], true),
);
$hint = $this->hint === null ? '' : hint_tooltip($this->hint, 'ml-1');
$hint = $this->hint === '' ? '' : new Hint([
'class' => 'ml-1',
'slot' => $this->hint,
])->render();
$this->mergeClass('inline-flex items-start gap-x-2');
$helperText = '';
if ($this->helper !== '') {
$helperId = $this->name . 'Help';
$helperText = new Helper([
'id' => $helperId,
'slot' => $this->helper,
'class' => '-mt-1',
])->render();
$this->attributes['aria-describedby'] = $helperId;
}
return <<<HTML
<label class="inline-flex items-center {$this->class}">{$checkboxInput}<span class="ml-2">{$this->slot}{$hint}</span></label>
<label {$this->getStringifiedAttributes()}>{$checkboxInput}
<div class="flex flex-col">
<span>{$this->slot}{$hint}</span>
{$helperText}
</div>
</label>
HTML;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class CodeEditor extends FormComponent
{
protected array $props = ['content', 'lang'];
protected array $attributes = [
'rows' => '6',
'class' => 'bg-elevated w-full rounded-lg border-3 border-contrast focus:border-contrast focus-within:ring-accent transition',
];
protected string $lang = '';
public function setValue(string $value): void
{
$this->value = htmlspecialchars_decode($value);
}
#[Override]
public function render(): string
{
$this->attributes['slot'] = 'textarea';
$textarea = form_textarea($this->attributes, $this->getValue());
return <<<HTML
<code-editor lang="{$this->lang}">{$textarea}</code-editor>
HTML;
}
}

View file

@ -4,17 +4,19 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class ColorRadioButton extends FormComponent
{
protected bool $isChecked = false;
protected array $props = ['isSelected'];
protected string $style = '';
protected array $casts = [
'isSelected' => 'boolean',
];
public function setIsChecked(string $value): void
{
$this->isChecked = $value === 'true';
}
protected bool $isSelected = false;
#[Override]
public function render(): string
{
$data = [
@ -23,18 +25,18 @@ class ColorRadioButton extends FormComponent
'class' => 'color-radio-btn',
];
if ($this->required) {
if ($this->isRequired) {
$data['required'] = 'required';
}
$radioInput = form_radio(
$data,
$this->value,
old($this->name) ? old($this->name) === $this->value : $this->isChecked,
old($this->name) ? old($this->name) === $this->value : $this->isSelected,
);
return <<<HTML
<div class="{$this->class}" style="{$this->style}">
<div {$this->getStringifiedAttributes()}>
{$radioInput}
<label for="{$this->value}" title="{$this->slot}" data-tooltip="bottom"></label>
</div>

View file

@ -4,23 +4,34 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class DatetimePicker extends FormComponent
{
protected array $attributes = [
'data-picker' => 'datetime',
];
#[Override]
public function render(): string
{
$this->attributes['class'] = 'rounded-l-lg border-0 border-rounded-r-none flex-1 focus:ring-0';
$this->attributes['data-input'] = '';
$dateInput = form_input($this->attributes, old($this->name, $this->value));
$dateInput = form_input([
'name' => $this->name,
'class' => 'rounded-l-lg border-0 border-rounded-r-none flex-1 focus:ring-0',
'data-input' => '',
], $this->getValue());
$clearLabel = lang(
'Episode.publish_form.scheduled_publication_date_clear',
);
$closeIcon = icon('close-fill');
$this->mergeClass('flex border-3 rounded-lg border-contrast focus-within:ring-accent transition');
return <<<HTML
<div class="flex border-3 rounded-lg border-contrast focus-within:ring-accent {$this->class}" data-picker="datetime">
<div {$this->getStringifiedAttributes()}>
{$dateInput}
<button class="p-3 bg-elevated hover:bg-base rounded-r-md focus:ring-inset focus:ring-accent" type="button" aria-label="{$clearLabel}" title="{$clearLabel}" data-clear="">
<button class="p-3 bg-elevated hover:bg-base rounded-r-md focus:ring-inset" type="button" aria-label="{$clearLabel}" title="{$clearLabel}" data-clear="">
{$closeIcon}
</button>
</div>

View file

@ -4,51 +4,80 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
class Field extends FormComponent
use Override;
use ViewComponents\Component;
class Field extends Component
{
protected array $props = [
'name',
'label',
'isRequired',
'isReadonly',
'as',
'hint',
'helper',
];
protected array $casts = [
'isRequired' => 'boolean',
'isReadonly' => 'boolean',
];
protected string $name;
protected string $label;
protected bool $isRequired = false;
protected bool $isReadonly = false;
protected string $as = 'Input';
protected string $label = '';
protected string $hint = '';
protected ?string $helper = null;
protected ?string $hint = null;
protected string $helper = '';
#[Override]
public function render(): string
{
$helperText = '';
if ($this->helper !== null) {
$helperId = $this->id . 'Help';
$helperText = '<Forms.Helper id="' . $helperId . '">' . $this->helper . '</Forms.Helper>';
if ($this->helper !== '') {
$helperId = $this->name . 'Help';
$helperText = new Helper([
'id' => $helperId,
'slot' => $this->helper,
])->render();
$this->attributes['aria-describedby'] = $helperId;
}
$labelAttributes = [
'for' => $this->id,
'isOptional' => $this->required ? 'false' : 'true',
'for' => $this->name,
'isOptional' => $this->isRequired ? 'false' : 'true',
'class' => '-mb-1',
'slot' => $this->label,
];
if ($this->hint) {
if ($this->hint !== '') {
$labelAttributes['hint'] = $this->hint;
}
$labelAttributes = stringify_attributes($labelAttributes);
$label = new Label($labelAttributes);
// remove field specific attributes to inject the rest to Form Component
$fieldComponentAttributes = $this->attributes;
unset($fieldComponentAttributes['as']);
unset($fieldComponentAttributes['label']);
unset($fieldComponentAttributes['class']);
unset($fieldComponentAttributes['helper']);
unset($fieldComponentAttributes['hint']);
$this->mergeClass('flex flex-col');
$fieldClass = $this->attributes['class'];
unset($this->attributes['class']);
$this->attributes['name'] = $this->name;
$this->attributes['isRequired'] = var_export($this->isRequired, true);
$this->attributes['isReadonly'] = var_export($this->isReadonly, true);
$element = __NAMESPACE__ . '\\' . $this->as;
$fieldElement = new $element($fieldComponentAttributes);
$fieldElement = new $element($this->attributes);
return <<<HTML
<div class="flex flex-col {$this->class}">
<Forms.Label {$labelAttributes}>{$this->label}</Forms.Label>
<div class="{$fieldClass}">
{$label->render()}
{$helperText}
<div class="w-full mt-1">
<div class="relative w-full mt-1">
{$fieldElement->render()}
</div>
</div>

View file

@ -6,52 +6,77 @@ namespace App\Views\Components\Forms;
use ViewComponents\Component;
class FormComponent extends Component
abstract class FormComponent extends Component
{
protected ?string $id = null;
protected array $props = [
'id',
'name',
'value',
'defaultValue',
'isRequired',
'isReadonly',
];
protected string $name = '';
protected array $casts = [
'isRequired' => 'boolean',
'isReadonly' => 'boolean',
];
protected string $value = '';
protected string $id;
protected bool $required = false;
protected string $name;
protected bool $readonly = false;
/**
* @var string|string[]|null
*/
protected string|array|null $value = null;
/**
* @var string|string[]|null
*/
protected string|array|null $defaultValue = null;
protected bool $isRequired = false;
protected bool $isReadonly = false;
/**
* @param array<string, string> $attributes
*/
public function __construct(array $attributes)
{
$parentVars = get_class_vars(self::class);
$this->casts = [...$parentVars['casts'], ...$this->casts];
$this->props = [...$parentVars['props'], $this->props];
parent::__construct($attributes);
if ($this->id === null) {
if (! isset($this->id)) {
$this->id = $this->name;
$this->attributes['id'] = $this->id;
}
}
public function setValue(string $value): void
{
$this->value = htmlspecialchars_decode($value, ENT_QUOTES);
}
$this->attributes['id'] = $this->id;
$this->attributes['name'] = $this->name;
public function setRequired(string $value): void
{
$this->required = $value === 'true';
unset($this->attributes['required']);
if ($this->required) {
if ($this->isRequired) {
$this->attributes['required'] = 'required';
}
}
public function setReadonly(string $value): void
{
$this->readonly = $value === 'true';
if ($this->readonly) {
if ($this->isReadonly) {
$this->attributes['readonly'] = 'readonly';
} else {
unset($this->attributes['readonly']);
}
}
protected function getValue(): string|array
{
$valueCast = $this->casts['value'] ?? '';
if ($valueCast === 'array') {
return old($this->name, in_array($this->value, [[], null], true) ? $this->defaultValue : $this->value) ?? [];
}
return old(
$this->name,
in_array($this->value, ['', null], true) ? $this->defaultValue : $this->value,
) ?? '';
}
}

View file

@ -4,19 +4,20 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
class Helper extends FormComponent
{
/**
* @var 'default'|'error'
*/
protected string $type = 'default';
use Override;
use ViewComponents\Component;
class Helper extends Component
{
// TODO: add type with error and show errors inline
#[Override]
public function render(): string
{
$class = 'text-skin-muted';
$this->mergeClass('form-helper');
return <<<HTML
<small id="{$this->id}" class="{$class} {$this->class}">{$this->slot}</small>
<small {$this->getStringifiedAttributes()}>{$this->slot}</small>
HTML;
}
}

View file

@ -4,28 +4,33 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class Input extends FormComponent
{
protected array $props = ['type'];
protected string $type = 'text';
#[Override]
public function render(): string
{
$baseClass = 'w-full border-contrast rounded-lg focus:border-contrast border-3 focus:ring-accent focus-within:ring-accent ' . $this->class;
$this->attributes['class'] = $baseClass;
$this->mergeClass('w-full border-contrast rounded-lg focus:border-contrast border-3 focus-within:ring-accent transition');
if ($this->type === 'file') {
$this->attributes['class'] .= ' file:px-3 file:py-2 file:h-[40px] file:font-semibold file:text-skin-muted file:text-sm file:rounded-none file:border-none file:bg-highlight file:cursor-pointer';
$this->mergeClass('file:px-3 file:py-2 file:h-[40px] file:font-semibold file:text-accent-hover file:text-sm file:rounded-none file:border-none file:bg-base file:cursor-pointer');
} else {
$this->attributes['class'] .= ' px-3 py-2';
$this->mergeClass('px-3 py-2');
}
if ($this->readonly) {
$this->attributes['class'] .= ' bg-base';
if ($this->isReadonly) {
$this->mergeClass('bg-base');
} else {
$this->attributes['class'] .= ' bg-elevated';
$this->mergeClass('bg-elevated');
}
return form_input($this->attributes, old($this->name, $this->value));
$this->attributes['type'] = $this->type;
return form_input($this->attributes, $this->getValue());
}
}

View file

@ -4,39 +4,42 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
use Override;
use ViewComponents\Component;
class Label extends Component
{
protected ?string $for = null;
protected array $props = ['for', 'hint', 'isOptional'];
protected ?string $hint = null;
protected array $casts = [
'isOptional' => 'boolean',
];
protected string $for;
protected string $hint = '';
protected bool $isOptional = false;
public function setIsOptional(string $value): void
{
$this->isOptional = $value === 'true';
}
#[Override]
public function render(): string
{
$labelClass = 'text-sm font-semibold ' . $this->attributes['class'];
unset($this->attributes['class']);
$this->mergeClass('text-sm font-semibold');
$optionalText = $this->isOptional ? '<small class="ml-1 font-normal lowercase">(' .
lang('Common.optional') .
')</small>' : '';
$hint = $this->hint === null ? '' : hint_tooltip($this->hint, 'ml-1');
unset($this->attributes['isOptional']);
unset($this->attributes['hint']);
unset($this->attributes['slot']);
$hint = $this->hint === '' ? '' : new Hint([
'class' => 'ml-1',
'slot' => $this->hint,
])->render();
$attributes = stringify_attributes($this->attributes);
$this->attributes['for'] = $this->for;
return <<<HTML
<label class="{$labelClass}" {$attributes}>{$this->slot}{$optionalText}{$hint}</label>
<label {$this->getStringifiedAttributes()}>{$this->slot}{$optionalText}{$hint}</label>
HTML;
}
}

View file

@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class MarkdownEditor extends FormComponent
{
protected array $props = ['disallowList'];
/**
* @var string[]
*/
@ -16,20 +20,23 @@ class MarkdownEditor extends FormComponent
$this->disallowList = explode(',', $value);
}
#[Override]
public function render(): string
{
$editorClass = 'w-full flex flex-col bg-elevated border-3 border-contrast rounded-lg overflow-hidden focus-within:ring-accent ' . $this->class;
$this->mergeClass('w-full flex flex-col bg-elevated border-3 border-contrast rounded-lg overflow-hidden focus-within:ring-accent transition');
$wrapperClass = $this->attributes['class'];
$this->attributes['class'] = 'bg-elevated border-none focus:border-none focus:outline-none focus:ring-0 w-full h-full';
$this->attributes['rows'] = 6;
$textarea = form_textarea($this->attributes, old($this->name, $this->value));
$markdownIcon = (string) icon(
'markdown-fill',
[
'class' => 'mr-1 text-lg opacity-40',
]
$textarea = form_textarea(
$this->attributes,
$this->getValue(),
);
$markdownIcon = (string) icon('markdown-fill', [
'class' => 'mr-1 text-lg opacity-40',
]);
$translations = [
'write' => lang('Common.forms.editor.write'),
'preview' => lang('Common.forms.editor.preview'),
@ -85,19 +92,19 @@ class MarkdownEditor extends FormComponent
$toolbarContent .= '<div class="inline-flex text-2xl gap-x-1">';
foreach ($buttonsGroup as $button) {
if (! in_array($button['name'], $this->disallowList, true)) {
$toolbarContent .= '<' . $button['tag'] . ' class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100">' . $button['icon'] . '</' . $button['tag'] . '>';
$toolbarContent .= '<' . $button['tag'] . ' class="opacity-50 hover:opacity-100 focus:opacity-100">' . $button['icon'] . '</' . $button['tag'] . '>';
}
}
$toolbarContent .= '</div>';
}
return <<<HTML
<div class="{$editorClass}">
<div class="{$wrapperClass}">
<header class="px-2">
<div class="sticky top-0 z-20 flex flex-wrap justify-between border-b border-gray-300 bg-elevated">
<markdown-write-preview for="{$this->id}" class="relative inline-flex h-8">
<button type="button" slot="write" class="px-2 font-semibold focus:ring-inset focus:ring-accent">{$translations['write']}</button>
<button type="button" slot="preview" class="px-2 font-semibold focus:ring-inset focus:ring-accent">{$translations['preview']}</button>
<button type="button" slot="write" class="px-2 font-semibold">{$translations['write']}</button>
<button type="button" slot="preview" class="px-2 font-semibold">{$translations['preview']}</button>
</markdown-write-preview>
<markdown-toolbar for="{$this->id}" class="flex gap-4 px-2 py-1">{$toolbarContent}</markdown-toolbar>
</div>

View file

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Views\Components\Forms;
class MultiSelect extends FormComponent
{
/**
* @var array<string, string>
*/
protected array $options = [];
/**
* @var string[]
*/
protected array $selected = [];
public function setOptions(string $value): void
{
$this->options = json_decode(htmlspecialchars_decode($value), true);
}
public function setSelected(string $selected): void
{
$this->selected = json_decode(htmlspecialchars_decode($selected), true);
}
public function render(): string
{
$defaultAttributes = [
'data-class' => $this->attributes['class'],
'multiple' => 'multiple',
'data-select-text' => lang('Common.forms.multiSelect.selectText'),
'data-loading-text' => lang('Common.forms.multiSelect.loadingText'),
'data-no-results-text' => lang('Common.forms.multiSelect.noResultsText'),
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
];
$this->attributes['class'] .= ' w-full bg-elevated border-3 border-contrast rounded-lg';
$extra = array_merge($defaultAttributes, $this->attributes);
return form_dropdown($this->name, $this->options, $this->selected, $extra);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class PermalinkEditor extends FormComponent
{
protected array $props = ['label', 'prefix', 'permalinkBase'];
protected string $label = '';
protected string $prefix = '';
protected string $permalinkBase = '';
#[Override]
public function render(): string
{
$this->mergeClass('flex-1 text-xs border-contrast rounded-lg focus:border-contrast border-3 focus-within:ring-accent transition');
$this->attributes['slot'] = 'slug-input';
$input = form_input($this->attributes, $this->getValue());
$editLabel = lang('Common.edit');
$copyLabel = lang('Common.copy');
$copiedLabel = lang('Common.copied');
return <<<HTML
<div>
<x-Forms.Label for="{$this->id}">{$this->label}</x-Forms.Label>
<permalink-edit class="inline-flex items-center w-full text-xs" edit-label="{$editLabel}" copy-label="{$copyLabel}" copied-label="{$copiedLabel}" permalink-base="{$this->permalinkBase}">
<span slot="domain">{$this->prefix}</span>
{$input}
</permalink-edit>
</div>
HTML;
}
}

View file

@ -4,29 +4,35 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class Radio extends FormComponent
{
protected array $props = ['isChecked'];
protected array $casts = [
'isChecked' => 'boolean',
];
protected bool $isChecked = false;
public function setIsChecked(string $value): void
{
$this->isChecked = $value === 'true';
}
#[Override]
public function render(): string
{
$radioInput = form_radio(
[
'id' => $this->value,
'name' => $this->name,
'class' => 'text-accent-base bg-elevated border-contrast border-3 focus:ring-accent w-6 h-6',
'class' => 'text-accent-base bg-elevated border-contrast border-3 focus:ring-accent w-6 h-6 transition',
],
$this->value,
$this->getValue(),
old($this->name) ? old($this->name) === $this->value : $this->isChecked,
);
$this->mergeClass('inline-flex items-center');
return <<<HTML
<label class="inline-flex items-center {$this->class}">{$radioInput}<span class="ml-2">{$this->slot}</span></label>
<label {$this->getStringifiedAttributes()}>{$radioInput}<span class="ml-2">{$this->slot}</span></label>
HTML;
}
}

View file

@ -4,17 +4,21 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class RadioButton extends FormComponent
{
protected bool $isChecked = false;
protected array $props = ['isSelected', 'description'];
protected ?string $hint = null;
protected array $casts = [
'isSelected' => 'boolean',
];
public function setIsChecked(string $value): void
{
$this->isChecked = $value === 'true';
}
protected bool $isSelected = false;
protected string $description = '';
#[Override]
public function render(): string
{
$data = [
@ -23,22 +27,34 @@ class RadioButton extends FormComponent
'class' => 'form-radio-btn bg-elevated',
];
if ($this->required) {
if ($this->isRequired) {
$data['required'] = 'required';
}
$this->mergeClass('relative w-full');
$descriptionText = '';
if ($this->description !== '') {
$describerId = $this->name . 'Help';
$descriptionText = <<<HTML
<span id="{$describerId}" class="form-radio-btn-description">{$this->description}</span>
HTML;
$data['aria-describedby'] = $describerId;
}
$radioInput = form_radio(
$data,
$this->value,
old($this->name) ? old($this->name) === $this->value : $this->isChecked,
$this->getValue(),
old($this->name) ? old($this->name) === $this->value : $this->isSelected,
);
$hint = $this->hint ? hint_tooltip($this->hint, 'ml-1 text-base') : '';
return <<<HTML
<div class="{$this->class}">
<div {$this->getStringifiedAttributes()}">
{$radioInput}
<label for="{$this->value}">{$this->slot}{$hint}</label>
<label for="{$this->value}">
<span>{$this->slot}</span>
{$descriptionText}
</label>
</div>
HTML;
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
use Override;
class RadioGroup extends FormComponent
{
protected array $props = ['label', 'options', 'hint', 'helper'];
protected array $casts = [
'options' => 'array',
];
protected string $label;
/**
* @var array{value:string,label:string,hint?:string}
*/
protected array $options = [];
protected string $hint = '';
protected string $helper = '';
#[Override]
public function render(): string
{
$this->mergeClass('flex flex-col');
$options = '';
foreach ($this->options as $option) {
$radioButtonData = [
'value' => $option['value'],
'name' => $this->name,
'slot' => $option['label'],
'description' => $option['description'] ?? '',
'isSelected' => var_export($this->getValue() === '' ? $option['value'] === $this->options[0]['value'] : $option['value'] === $this->getValue(), true),
'isRequired' => var_export($this->isRequired, true),
];
$options .= new RadioButton($radioButtonData)->render();
}
$helperText = '';
if ($this->helper !== '') {
$helperId = $this->name . 'Help';
$helperText = new Helper([
'id' => $helperId,
'slot' => $this->helper,
])->render();
$this->attributes['aria-describedby'] = $helperId;
}
$hint = $this->hint === '' ? '' : new Hint([
'class' => 'ml-1',
'slot' => $this->hint,
])->render();
return <<<HTML
<fieldset {$this->getStringifiedAttributes()}>
<legend class="-mb-1 text-sm font-semibold">{$this->label}{$hint}</legend>
{$helperText}
<div class="grid grid-cols-radioGroup gap-2 mt-1">{$options}</div>
</fieldset>
HTML;
}
}

View file

@ -4,21 +4,27 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
use ViewComponents\Component;
class Section extends Component
{
protected string $title = '';
protected array $props = ['title', 'subtitle'];
protected ?string $subtitle = null;
protected string $title;
protected string $subtitle = '';
#[Override]
public function render(): string
{
$subtitle = $this->subtitle === null ? '' : '<p class="text-sm text-skin-muted">' . $this->subtitle . '</p>';
$subtitle = $this->subtitle === '' ? '' : '<p class="text-sm text-skin-muted">' . $this->subtitle . '</p>';
$this->mergeClass('w-full p-4 sm:p-6 md:p-8 bg-elevated border-3 flex flex-col items-start border-subtle rounded-xl');
return <<<HTML
<fieldset class="w-full p-8 bg-elevated border-3 flex flex-col items-start border-subtle rounded-xl {$this->class}">
<Heading tagName="legend" class="float-left">{$this->title}</Heading>
<fieldset {$this->getStringifiedAttributes()}>
<x-Heading tagName="legend" class="float-left">{$this->title}</x-Heading>
{$subtitle}
<div class="flex flex-col w-0 min-w-full gap-4 py-4">{$this->slot}</div>
</fieldset>

View file

@ -4,36 +4,44 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class Select extends FormComponent
{
protected array $props = ['options'];
protected array $casts = [
'options' => 'array',
];
/**
* @var array<string, string>
* @var array<array<string, string>>
*/
protected array $options = [];
protected string $selected = '';
public function setOptions(string $value): void
{
$this->options = json_decode(htmlspecialchars_decode($value), true);
}
#[Override]
public function render(): string
{
$this->mergeClass('w-full focus:border-contrast border-3 rounded-lg bg-elevated border-contrast');
$defaultAttributes = [
'class' => 'w-full focus:border-contrast focus:ring-accent border-3 rounded-lg bg-elevated border-contrast ' . $this->class,
'data-class' => $this->class,
'data-select-text' => lang('Common.forms.multiSelect.selectText'),
'data-loading-text' => lang('Common.forms.multiSelect.loadingText'),
'data-no-results-text' => lang('Common.forms.multiSelect.noResultsText'),
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
];
unset($this->attributes['name']);
unset($this->attributes['options']);
unset($this->attributes['selected']);
$extra = [...$this->attributes, ...$defaultAttributes];
$this->attributes = [...$defaultAttributes, ...$this->attributes];
return form_dropdown($this->name, $this->options, old($this->name, $this->selected !== '' ? [$this->selected] : []), $extra);
$options = '';
$selected = $this->getValue();
foreach ($this->options as $option) {
$options .= '<option ' . (array_key_exists('description', $option) ? 'data-label-description="' . $option['description'] . '" ' : '') . 'value="' . $option['value'] . '"' . ($option['value'] === $selected ? ' selected' : '') . '>' . $option['label'] . '</option>';
}
$this->attributes['name'] = $this->name;
return <<<HTML
<select {$this->getStringifiedAttributes()}>{$options}</select>
HTML;
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class SelectMulti extends FormComponent
{
protected array $props = ['options'];
protected array $casts = [
'value' => 'array',
'defaultValue' => 'array',
'options' => 'array',
];
/**
* @var array<array<string, string>>
*/
protected array $options = [];
#[Override]
public function render(): string
{
$this->mergeClass('w-full bg-elevated border-3 border-contrast rounded-lg relative');
$defaultAttributes = [
'multiple' => 'multiple',
'data-select-text' => lang('Common.forms.multiSelect.selectText'),
'data-loading-text' => lang('Common.forms.multiSelect.loadingText'),
'data-no-results-text' => lang('Common.forms.multiSelect.noResultsText'),
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
];
$this->attributes = [...$defaultAttributes, ...$this->attributes];
$options = '';
$selected = $this->getValue();
foreach ($this->options as $option) {
$options .= '<option ' . (array_key_exists('description', $option) ? 'data-label-description="' . $option['description'] . '" ' : '') . 'value="' . $option['value'] . '"' . (in_array($option['value'], $selected, true) ? ' selected' : '') . '>' . $option['label'] . '</option>';
}
$this->attributes['name'] = $this->name . '[]';
return <<<HTML
<select {$this->getStringifiedAttributes()}>{$options}</select>
HTML;
}
}

View file

@ -4,25 +4,27 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use Override;
class Textarea extends FormComponent
{
public function setValue(?string $value): void
protected array $attributes = [
'rows' => '6',
];
public function setValue(string $value): void
{
if ($value) {
$this->value = htmlspecialchars_decode($value);
}
$this->value = htmlspecialchars_decode($value);
}
#[Override]
public function render(): string
{
unset($this->attributes['value']);
$this->mergeClass('bg-elevated w-full rounded-lg border-3 border-contrast focus:border-contrast focus-within:ring-accent transition');
$this->attributes['class'] = 'bg-elevated w-full focus:border-contrast focus:ring-accent rounded-lg border-3 border-contrast ' . $this->class;
$this->attributes['id'] = $this->id;
$textarea = form_textarea(
$this->attributes,
old($this->name, $this->value ?? '', false)
);
$textarea = form_textarea($this->attributes, $this->getValue());
return <<<HTML
{$textarea}

View file

@ -4,45 +4,62 @@ declare(strict_types=1);
namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
use Override;
class Toggler extends FormComponent
{
/**
* @var 'base'|'small
*/
protected string $size = 'base';
protected array $props = ['size', 'hint', 'helper'];
protected string $label = '';
protected array $casts = [
'isChecked' => 'boolean',
];
protected string $hint = '';
protected bool $checked = false;
protected string $helper = '';
public function setChecked(string $value): void
{
$this->checked = $value === 'true';
}
protected bool $isChecked = false;
#[Override]
public function render(): string
{
unset($this->attributes['checked']);
$this->mergeClass('relative justify-between inline-flex items-start gap-x-2');
$wrapperClass = $this->class;
unset($this->attributes['class']);
$checkbox = form_checkbox(
[
'id' => $this->id,
'name' => $this->name,
'class' => 'form-switch',
],
'yes',
in_array($this->getValue(), ['yes', 'true', 'on', '1'], true),
);
$sizeClass = [
'base' => 'form-switch-slider',
'small' => 'form-switch-slider form-switch-slider--small',
];
$hint = $this->hint === '' ? '' : new Hint([
'class' => 'ml-1',
'slot' => $this->hint,
])->render();
$this->attributes['class'] = 'form-switch';
$helperText = '';
if ($this->helper !== '') {
$helperId = $this->name . 'Help';
$helperText = new Helper([
'id' => $helperId,
'slot' => $this->helper,
'class' => '-mt-1',
])->render();
$this->attributes['aria-describedby'] = $helperId;
}
$checkbox = form_checkbox($this->attributes, $this->value, old($this->name) === 'yes' ? true : $this->checked);
$hint = $this->hint === '' ? '' : hint_tooltip($this->hint, 'ml-1');
return <<<HTML
<label class="relative inline-flex items-center {$wrapperClass}">
<label {$this->getStringifiedAttributes()}>
<div class="flex flex-col">
<span>{$this->slot}{$hint}</span>
{$helperText}
</div>
{$checkbox}
<span class="{$sizeClass[$this->size]}"></span>
<span class="ml-2">{$this->slot}{$hint}</span>
<span class="form-switch-slider"></span>
</label>
HTML;
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Views\Components\Forms;
class XMLEditor extends FormComponent
{
/**
* @var array<string, string>
*/
protected array $attributes = [
'rows' => '5',
'class' => 'textarea',
];
protected string $content = '';
public function setContent(string $value): void
{
$this->content = htmlspecialchars_decode($value);
}
public function render(): string
{
$this->attributes['slot'] = 'textarea';
$textarea = form_textarea($this->attributes, $this->content);
return <<<HTML
<xml-editor>{$textarea}</time-ago>
HTML;
}
}

View file

@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Views\Components;
use Override;
use ViewComponents\Component;
class Heading extends Component
{
protected array $props = ['tagName', 'size'];
protected string $tagName = 'div';
/**
@ -15,18 +18,20 @@ class Heading extends Component
*/
protected string $size = 'base';
#[Override]
public function render(): string
{
$sizeClasses = [
$sizeClass = match ($this->size) {
'small' => 'tracking-wide text-base',
'base' => 'text-xl',
'large' => 'text-3xl',
];
default => 'text-xl',
};
$class = $this->class . ' relative z-10 font-bold text-heading-foreground font-display before:w-full before:absolute before:h-1/2 before:left-0 before:bottom-0 before:rounded-full before:bg-heading-background before:z-[-10] ' . $sizeClasses[$this->size];
$this->mergeClass('relative z-10 font-bold text-heading-foreground font-display before:w-full before:absolute before:h-1/2 before:left-0 before:bottom-0 before:rounded-full before:bg-heading-background before:z-[-10]');
$this->mergeClass($sizeClass);
return <<<HTML
<{$this->tagName} class="{$class}">{$this->slot}</{$this->tagName}>
<{$this->tagName} {$this->getStringifiedAttributes()}>{$this->slot}</{$this->tagName}>
HTML;
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Views\Components;
use Override;
use ViewComponents\Component;
class Hint extends Component
{
protected array $attributes = [
'data-tooltip' => 'bottom',
'tabindex' => '0',
];
#[Override]
public function render(): string
{
$this->attributes['title'] = $this->slot;
$this->mergeClass('inline-block align-middle opacity-75');
$icon = icon('question-fill');
return <<<HTML
<span {$this->getStringifiedAttributes()}>{$icon}</span>
HTML;
}
}

View file

@ -6,7 +6,9 @@ namespace App\Views\Components;
class IconButton extends Button
{
public string $glyph = '';
protected array $props = ['glyph'];
protected string $glyph;
public function __construct(array $attributes)
{
@ -16,18 +18,18 @@ class IconButton extends Button
'data-tooltip' => 'bottom',
];
$glyphSize = [
'small' => 'text-sm',
'base' => 'text-lg',
'large' => 'text-2xl',
];
$allAttributes = [...$attributes, ...$iconButtonAttributes];
parent::__construct($allAttributes);
$glyphSizeClass = match ($this->size) {
'small' => 'text-sm',
'large' => 'text-2xl',
default => 'text-lg',
};
$this->slot = (string) icon($this->glyph, [
'class' => $glyphSize[$this->size],
'class' => $glyphSizeClass,
]);
}
}

View file

@ -4,40 +4,57 @@ declare(strict_types=1);
namespace App\Views\Components;
use Override;
use ViewComponents\Component;
class Pill extends Component
{
protected array $props = ['size', 'variant', 'icon', 'iconClass', 'hint'];
/**
* @var 'small'|'base'
*/
public string $size = 'base';
protected string $size = 'base';
public string $variant = 'default';
protected string $variant = 'default';
public ?string $icon = null;
protected string $icon = '';
public ?string $iconClass = '';
protected string $iconClass = '';
protected ?string $hint = null;
protected string $hint = '';
#[Override]
public function render(): string
{
$variantClasses = [
'default' => 'text-gray-800 bg-gray-100 border-gray-300',
$variantClass = match ($this->variant) {
'primary' => 'text-accent-contrast bg-accent-base border-accent-base',
'success' => 'text-pine-900 bg-pine-100 border-pine-300',
'danger' => 'text-red-900 bg-red-100 border-red-300',
'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
];
default => 'text-gray-800 bg-gray-100 border-gray-300',
};
$icon = $this->icon ? icon($this->icon, [
$sizeClass = match ($this->size) {
'small' => 'text-xs tracking-wide',
default => 'text-sm',
};
$icon = $this->icon !== '' ? icon($this->icon, [
'class' => $this->iconClass,
]) : '';
$hint = $this->hint ? 'data-tooltip="bottom" title="' . $this->hint . '"' : '';
if ($this->hint !== '') {
$this->attributes['data-tooltip'] = 'bottom';
$this->attributes['title'] = $this->hint;
}
$this->mergeClass('inline-flex lowercase items-center gap-x-1 px-1 font-semibold border rounded');
$this->mergeClass($variantClass);
$this->mergeClass($sizeClass);
return <<<HTML
<span class="inline-flex items-center gap-x-1 px-1 font-semibold text-sm border rounded {$variantClasses[$this->variant]} {$this->class}" {$hint}>{$icon}{$this->slot}</span>
<span {$this->getStringifiedAttributes()}>{$icon}{$this->slot}</span>
HTML;
}
}

View file

@ -4,21 +4,29 @@ declare(strict_types=1);
namespace App\Views\Components;
use Override;
use ViewComponents\Component;
class ReadMore extends Component
{
public string $id;
protected array $props = ['id'];
protected string $id;
#[Override]
public function render(): string
{
$readMoreLabel = lang('Common.read_more');
$readLessLabel = lang('Common.read_less');
$this->mergeClass('read-more');
$this->attributes['style'] = '--line-clamp: 3';
return <<<HTML
<div class="read-more {$this->class}" style="--line-clamp: 3">
<div {$this->getStringifiedAttributes()}>
<input id="read-more-checkbox_{$this->id}" type="checkbox" class="read-more__checkbox" aria-hidden="true">
<div class="mb-2 read-more__text">{$this->slot}</div>
<label for="read-more-checkbox_{$this->id}" class="read-more__label" data-read-more="{$readMoreLabel}" data-read-less="{$readLessLabel}" aria-hidden="true"></label>
<div class="read-more__text">{$this->slot}</div>
<label for="read-more-checkbox_{$this->id}" class="mt-2 read-more__label" data-read-more="{$readMoreLabel}" data-read-less="{$readLessLabel}" aria-hidden="true"></label>
</div>
HTML;
}

View file

@ -4,19 +4,25 @@ declare(strict_types=1);
namespace App\Views\Components;
use Override;
use ViewComponents\Component;
class SeeMore extends Component
{
#[Override]
public function render(): string
{
$seeMoreLabel = lang('Common.see_more');
$seeLessLabel = lang('Common.see_less');
$this->mergeClass('see-more');
$this->attributes['styles'] = '--content-height: 10rem';
return <<<HTML
<div class="see-more" style="--content-height: 10rem">
<div {$this->getStringifiedAttributes()}>
<input id="see-more-checkbox" type="checkbox" class="see-more__checkbox" aria-hidden="true">
<div class="mb-2 see-more__content {$this->class}"><div class="see-more_content-fade"></div>{$this->slot}</div>
<label for="see-more-checkbox" class="see-more__label" data-see-more="{$seeMoreLabel}" data-see-less="{$seeLessLabel}" aria-hidden="true"></label>
<div class="see-more__content"><div class="see-more_content-fade"></div>{$this->slot}</div>
<label for="see-more-checkbox" class="mt-2 see-more__label" data-see-more="{$seeMoreLabel}" data-see-less="{$seeLessLabel}" aria-hidden="true"></label>
</div>
HTML;
}

View file

@ -1,18 +1,18 @@
<?php declare(strict_types=1);
if (session()->has('message')): ?>
<Alert variant="success" class="mb-4"><?= esc(session('message')) ?></Alert>
<Alert variant="success" class="mb-4"><?= session('message') ?></Alert>
<?php endif; ?>
<?php if (session()->has('error')): ?>
<Alert variant="danger" class="mb-4"><?= esc(session('error')) ?></Alert>
<Alert variant="danger" class="mb-4"><?= session('error') ?></Alert>
<?php endif; ?>
<?php if (session()->has('errors')): ?>
<Alert variant="danger" class="mb-4">
<ul>
<?php foreach (session('errors') as $error): ?>
<li><?= esc($error) ?></li>
<li><?= $error ?></li>
<?php endforeach; ?>
</ul>
</Alert>

View file

@ -49,6 +49,7 @@ p.lead {
.header {
background: var(--light-bg-color);
color: var(--dark-text-color);
margin-top: 2.17rem;
}
.header .container {

View file

@ -1,29 +1,84 @@
<?= helper(['components', 'svg']) ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title><?= lang('Errors.badRequest') ?></title>
<title><?= lang('Errors.pageNotFound') ?></title>
<link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
<?= service('vite')->asset('styles/index.css') ?>
<style>
div.logo {
height: 200px;
width: 155px;
display: inline-block;
opacity: 0.08;
position: absolute;
top: 2rem;
left: 50%;
margin-left: -73px;
}
body {
height: 100%;
background: #fafafa;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #777;
font-weight: 300;
}
h1 {
font-weight: lighter;
letter-spacing: normal;
font-size: 3rem;
margin-top: 0;
margin-bottom: 0;
color: #222;
}
.wrap {
max-width: 1024px;
margin: 5rem auto;
padding: 2rem;
background: #fff;
text-align: center;
border: 1px solid #efefef;
border-radius: 0.5rem;
position: relative;
}
pre {
white-space: normal;
margin-top: 1.5rem;
}
code {
background: #fafafa;
border: 1px solid #efefef;
padding: 0.5rem 1rem;
border-radius: 5px;
display: block;
}
p {
margin-top: 1.5rem;
}
.footer {
margin-top: 2rem;
border-top: 1px solid #efefef;
padding: 1em 2em 0 2em;
font-size: 85%;
color: #999;
}
a:active,
a:link,
a:visited {
color: #dd4814;
}
</style>
</head>
<body>
<div class="wrap">
<h1>400</h1>
<body class="flex flex-col items-center justify-center min-h-screen px-2 text-center bg-base theme-<?= service('settings')
->get('App.theme') ?>">
<?= svg('castopod-mascot_confused', 'h-64') ?>
<h1 class="mt-4 text-3xl font-bold font-display md:text-4xl lg:text-5xl">400</h1>
<p class="mb-6 text-lg text-skin-muted md:text-xl lg:text-2xl">
<p>
<?php if (ENVIRONMENT !== 'production') : ?>
<?= nl2br(esc($message)) ?>
<?php else : ?>
<?= lang('Errors.sorryBadRequest') ?>
<?php endif; ?>
</p>
<a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast focus:ring-accent md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a>
</div>
</body>
</html>

View file

@ -1,6 +1,7 @@
<?= helper(['components', 'svg']) ?>
<!DOCTYPE html>
<html lang="en">
<html lang="<?= service('request')
->getLocale() ?>">
<head>
<meta charset="utf-8">
@ -8,7 +9,7 @@
<title>403 Forbidden</title>
<link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
<?= service('vite')->asset('styles/index.css') ?>
<?= service('vite')->asset('styles/index.css', 'css') ?>
</head>
<body class="flex flex-col items-center justify-center min-h-screen px-2 text-center bg-base theme-<?= service('settings')
@ -23,7 +24,7 @@
You do not have sufficient permissions to access that page.
<?php endif; ?>
</p>
<a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast focus:ring-accent md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a>
<a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a>
</body>
</html>

View file

@ -1,6 +1,7 @@
<?= helper(['components', 'svg']) ?>
<!DOCTYPE html>
<html lang="en">
<html lang="<?= service('request')
->getLocale() ?>">
<head>
<meta charset="utf-8">
@ -8,7 +9,7 @@
<title><?= lang('Errors.pageNotFound') ?></title>
<link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
<?= service('vite')->asset('styles/index.css') ?>
<?= service('vite')->asset('styles/index.css', 'css') ?>
</head>
<body class="flex flex-col items-center justify-center min-h-screen px-2 text-center bg-base theme-<?= service('settings')
@ -23,7 +24,7 @@
<?= lang('Errors.sorryCannotFind') ?>
<?php endif; ?>
</p>
<a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast focus:ring-accent md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a>
<a href="<?= previous_url() ?>" class="inline-flex items-center justify-center px-3 py-1 text-sm font-semibold rounded-full shadow-xs text-accent-contrast md:px-4 md:py-2 md:text-base bg-accent-base hover:bg-accent-hover"><?= lang('Common.go_back') ?></a>
</body>
</html>

View file

@ -9,9 +9,9 @@
<title><?= lang('Errors.whoops') ?></title>
<link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
<?= service('vite')->asset('styles/index.css') ?>
<?= service('vite')->asset('styles/index.css', 'css') ?>
<?php if (auth()->loggedIn()): ?>
<?= service('vite')->asset('js/error.ts') ?>
<?= service('vite')->asset('js/error.ts', 'js') ?>
<?php endif; ?>
</head>
@ -28,7 +28,7 @@
<h2 class="font-mono font-semibold"><?= esc($title), esc($exception->getCode() ? ' #' . $exception->getCode() : '') ?></h2>
<p class="font-mono"><?= nl2br(esc($exception->getMessage())) ?><br/><span class="pl-4">at <span class="select-all bg-elevated"><?= nl2br(esc($exception->getFile())) ?>:<?= esc($exception->getLine()) ?></span></span></p>
<p id="error-stack-trace" class="hidden"><?= nl2br(esc($exception)) ?></p>
<clipboard-copy for="error-stack-trace" class="items-center self-end px-3 py-1 mt-2 font-semibold leading-8 transition-all rounded-full shadow group text-accent-contrast hover:bg-accent-hover bg-accent-base focus:ring-accent">
<clipboard-copy for="error-stack-trace" class="items-center self-end px-3 py-1 mt-2 font-semibold leading-8 transition-all rounded-full shadow group text-accent-contrast hover:bg-accent-hover bg-accent-base">
<span class="inline-flex items-center copy-base"><?= icon('file-copy-fill', [
'class' => 'mr-2',
]) ?>Copy stack trace</span>
@ -41,11 +41,11 @@
<div class="flex flex-col justify-center w-full gap-6 py-12 border-t-2 md:flex-row border-subtle">
<div class="w-full max-w-md mx-auto md:mx-0">
<h2 class="text-xl font-semibold font-display">Found a bug?</h2>
<p>You can help get it fixed by <a href="https://castopod.org/new-issue_bug" target="_blank" rel="noopener noreferrer" class="underline decoration-3 hover:no-underline focus:ring-accent decoration-accent">creating an issue on the Castopod issue tracker</a>. Please check that the issue does not already exist beforehand.</p>
<p>You can help get it fixed by <a href="https://castopod.org/new-issue_bug" target="_blank" rel="noopener noreferrer" class="underline decoration-3 hover:no-underline decoration-accent">creating an issue on the Castopod issue tracker</a>. Please check that the issue does not already exist beforehand.</p>
</div>
<div class="w-full max-w-md mx-auto md:mx-0">
<h2 class="text-xl font-semibold font-display">Not sure what's happening?</h2>
<p>You can ask for help in the <a href="https://castopod.org/chat" target="_blank" rel="noopener noreferrer" class="underline decoration-2 hover:no-underline focus:ring-accent decoration-accent">Castopod community chat</a>!</p>
<p>You can ask for help in the <a href="https://castopod.org/chat" target="_blank" rel="noopener noreferrer" class="underline decoration-2 hover:no-underline decoration-accent">Castopod community chat</a>!</p>
</div>
</div>
<?php else: ?>

View file

@ -1,20 +1,21 @@
{
"name": "adaures/castopod",
"version": "1.15.5",
"version": "2.0.0-dev",
"type": "project",
"description": "Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience.",
"homepage": "https://castopod.org",
"license": "AGPL-3.0-or-later",
"require": {
"php": "^8.1",
"php": "^8.5",
"adaures/castopod-plugins-manager": "dev-main",
"adaures/ipcat-php": "^v1.0.0",
"adaures/podcast-persons-taxonomy": "^v1.0.1",
"aws/aws-sdk-php": "^3.369.36",
"aws/aws-sdk-php": "^3.369.37",
"chrisjean/php-ico": "^1.0.4",
"cocur/slugify": "^v4.7.1",
"codeigniter4/framework": "4.6.5",
"cocur/slugify": "4.7.1",
"codeigniter4/framework": "4.7.0",
"codeigniter4/settings": "v2.2.0",
"codeigniter4/shield": "^1.2.0",
"codeigniter4/shield": "1.2.0",
"codeigniter4/tasks": "dev-develop",
"geoip2/geoip2": "3.3.0",
"james-heinrich/getid3": "^2.0.0-beta6",
@ -25,19 +26,19 @@
"mpratt/embera": "^2.0.42",
"opawg/user-agents-v2-php": "dev-main",
"phpseclib/phpseclib": "~2.0.51",
"vlucas/phpdotenv": "^5.6.3",
"vlucas/phpdotenv": "5.6.3",
"whichbrowser/parser": "^v2.1.8",
"yassinedoghri/codeigniter-vite": "^2.1",
"yassinedoghri/php-icons": "^1.3.0",
"yassinedoghri/codeigniter-vite": "^2.1.0",
"yassinedoghri/php-icons": "1.3.0",
"yassinedoghri/podcast-feed": "dev-main"
},
"require-dev": {
"captainhook/captainhook": "^5.28.3",
"codeigniter/phpstan-codeigniter": "^1.5.4",
"mikey179/vfsstream": "v1.6.12",
"codeigniter/phpstan-codeigniter": "1.5.4",
"mikey179/vfsstream": "^v1.6.12",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.39",
"phpunit/phpunit": "^10.5.63",
"phpunit/phpunit": "^13.0.5",
"rector/rector": "^2.3.6",
"symplify/coding-standard": "^13.0.0",
"symplify/easy-coding-standard": "^13.0.4"

1098
composer.lock generated
View file

@ -4,8 +4,63 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ae175922494cbac88f4477946a143ae8",
"content-hash": "de1c665976cbb37fec3de326336d83f5",
"packages": [
{
"name": "adaures/castopod-plugins-manager",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/ad-aures/castopod-plugins-manager.git",
"reference": "53430f9a57cd38eee3e3dfe5953764cc42c2a0c9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ad-aures/castopod-plugins-manager/zipball/53430f9a57cd38eee3e3dfe5953764cc42c2a0c9",
"reference": "53430f9a57cd38eee3e3dfe5953764cc42c2a0c9",
"shasum": ""
},
"require": {
"php": ">=8.4",
"z4kn4fein/php-semver": "^3.0"
},
"require-dev": {
"pestphp/pest": "^4.0.4",
"pestphp/pest-plugin-type-coverage": "^4.0.2",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.22",
"rector/rector": "^2.1.4",
"symplify/coding-standard": "^12.4.3",
"symplify/easy-coding-standard": "^12.5.24"
},
"default-branch": true,
"type": "library",
"autoload": {
"files": [
"src/Constants.php",
"src/helpers.php"
],
"psr-4": {
"Castopod\\PluginsManager\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"AGPL-3.0-only"
],
"authors": [
{
"name": "Yassine Doghri",
"homepage": "https://yassinedoghri.com/"
}
],
"description": "A PHP library to install, update, and remove plugins on a Castopod instance.",
"support": {
"issues": "https://github.com/ad-aures/castopod-plugins-manager/issues",
"source": "https://github.com/ad-aures/castopod-plugins-manager/tree/main"
},
"time": "2025-10-06T15:58:43+00:00"
},
{
"name": "adaures/ipcat-php",
"version": "v1.0.0",
@ -206,16 +261,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.369.36",
"version": "3.369.37",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0"
"reference": "bc599ce989b101ee630d5e1a1aeda387632df47b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0",
"reference": "2a69e7df5e03be9e08f9f73fb6a8cc9dd63b59c0",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/bc599ce989b101ee630d5e1a1aeda387632df47b",
"reference": "bc599ce989b101ee630d5e1a1aeda387632df47b",
"shasum": ""
},
"require": {
@ -297,9 +352,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.369.36"
"source": "https://github.com/aws/aws-sdk-php/tree/3.369.37"
},
"time": "2026-02-17T19:45:01+00:00"
"time": "2026-02-18T19:16:34+00:00"
},
{
"name": "brick/math",
@ -484,23 +539,23 @@
},
{
"name": "codeigniter4/framework",
"version": "v4.6.5",
"version": "v4.7.0",
"source": {
"type": "git",
"url": "https://github.com/codeigniter4/framework.git",
"reference": "116e0919590a412c09d2b9e4f6b8addda18224d8"
"reference": "e7753bc03f8b74af428f46b5e2bb74925487c930"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/codeigniter4/framework/zipball/116e0919590a412c09d2b9e4f6b8addda18224d8",
"reference": "116e0919590a412c09d2b9e4f6b8addda18224d8",
"url": "https://api.github.com/repos/codeigniter4/framework/zipball/e7753bc03f8b74af428f46b5e2bb74925487c930",
"reference": "e7753bc03f8b74af428f46b5e2bb74925487c930",
"shasum": ""
},
"require": {
"ext-intl": "*",
"ext-mbstring": "*",
"laminas/laminas-escaper": "^2.17",
"php": "^8.1",
"laminas/laminas-escaper": "^2.18",
"php": "^8.2",
"psr/log": "^3.0"
},
"require-dev": {
@ -514,6 +569,7 @@
"predis/predis": "^3.0"
},
"suggest": {
"ext-apcu": "If you use Cache class ApcuHandler",
"ext-curl": "If you use CURLRequest class",
"ext-dom": "If you use TestResponse",
"ext-exif": "If you run Image class tests",
@ -525,7 +581,9 @@
"ext-memcached": "If you use Cache class MemcachedHandler with Memcached",
"ext-mysqli": "If you use MySQL",
"ext-oci8": "If you use Oracle Database",
"ext-pcntl": "If you use Signals",
"ext-pgsql": "If you use PostgreSQL",
"ext-posix": "If you use Signals",
"ext-readline": "Improves CLI::input() usability",
"ext-redis": "If you use Cache class RedisHandler",
"ext-simplexml": "If you format XML",
@ -554,7 +612,7 @@
"slack": "https://codeigniterchat.slack.com",
"source": "https://github.com/codeigniter4/CodeIgniter4"
},
"time": "2026-02-01T17:59:34+00:00"
"time": "2026-02-01T20:39:35+00:00"
},
{
"name": "codeigniter4/queue",
@ -3201,25 +3259,25 @@
},
{
"name": "symfony/filesystem",
"version": "v7.4.0",
"version": "v8.0.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "d551b38811096d0be9c4691d406991b47c0c630a"
"reference": "d937d400b980523dc9ee946bb69972b5e619058d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a",
"reference": "d551b38811096d0be9c4691d406991b47c0c630a",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d",
"reference": "d937d400b980523dc9ee946bb69972b5e619058d",
"shasum": ""
},
"require": {
"php": ">=8.2",
"php": ">=8.4",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^6.4|^7.0|^8.0"
"symfony/process": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@ -3247,7 +3305,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v7.4.0"
"source": "https://github.com/symfony/filesystem/tree/v8.0.1"
},
"funding": [
{
@ -3267,7 +3325,7 @@
"type": "tidelift"
}
],
"time": "2025-11-27T13:27:24+00:00"
"time": "2025-12-01T09:13:36+00:00"
},
{
"name": "symfony/polyfill-ctype",
@ -3836,6 +3894,60 @@
"source": "https://github.com/yassinedoghri/podcast-feed/tree/main"
},
"time": "2024-04-28T16:17:41+00:00"
},
{
"name": "z4kn4fein/php-semver",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/z4kn4fein/php-semver.git",
"reference": "049a1d81e92235c8b3c9ab30a96fcbaa929a266d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/z4kn4fein/php-semver/zipball/049a1d81e92235c8b3c9ab30a96fcbaa929a266d",
"reference": "049a1d81e92235c8b3c9ab30a96fcbaa929a266d",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^10"
},
"type": "library",
"autoload": {
"psr-4": {
"z4kn4fein\\SemVer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Peter Csajtai",
"email": "peter.csajtai@outlook.com"
}
],
"description": "Semantic Versioning library for PHP. It implements the full semantic version 2.0.0 specification and provides ability to parse, compare, and increment semantic versions along with validation against constraints.",
"homepage": "https://github.com/z4kn4fein/php-semver",
"keywords": [
"comparison",
"semantic",
"semver",
"validation",
"version",
"versioning"
],
"support": {
"issues": "https://github.com/z4kn4fein/php-semver/issues",
"source": "https://github.com/z4kn4fein/php-semver/tree/v3.0.0"
},
"time": "2024-04-01T16:17:27+00:00"
}
],
"packages-dev": [
@ -4940,35 +5052,34 @@
},
{
"name": "phpunit/php-code-coverage",
"version": "10.1.16",
"version": "13.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "7e308268858ed6baedc8704a304727d20bc07c77"
"reference": "a8b58fde2f4fbc69a064e1f80ff917607cf7737c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77",
"reference": "7e308268858ed6baedc8704a304727d20bc07c77",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a8b58fde2f4fbc69a064e1f80ff917607cf7737c",
"reference": "a8b58fde2f4fbc69a064e1f80ff917607cf7737c",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"ext-xmlwriter": "*",
"nikic/php-parser": "^4.19.1 || ^5.1.0",
"php": ">=8.1",
"phpunit/php-file-iterator": "^4.1.0",
"phpunit/php-text-template": "^3.0.1",
"sebastian/code-unit-reverse-lookup": "^3.0.0",
"sebastian/complexity": "^3.2.0",
"sebastian/environment": "^6.1.0",
"sebastian/lines-of-code": "^2.0.2",
"sebastian/version": "^4.0.1",
"theseer/tokenizer": "^1.2.3"
"nikic/php-parser": "^5.7.0",
"php": ">=8.4",
"phpunit/php-file-iterator": "^7.0",
"phpunit/php-text-template": "^6.0",
"sebastian/complexity": "^6.0",
"sebastian/environment": "^9.0",
"sebastian/lines-of-code": "^5.0",
"sebastian/version": "^7.0",
"theseer/tokenizer": "^2.0.1"
},
"require-dev": {
"phpunit/phpunit": "^10.1"
"phpunit/phpunit": "^13.0"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@ -4977,7 +5088,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "10.1.x-dev"
"dev-main": "13.0.x-dev"
}
},
"autoload": {
@ -5006,40 +5117,52 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16"
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/13.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
"type": "tidelift"
}
],
"time": "2024-08-22T04:31:57+00:00"
"time": "2026-02-06T06:05:15+00:00"
},
{
"name": "phpunit/php-file-iterator",
"version": "4.1.0",
"version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
"reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c"
"reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c",
"reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50",
"reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "4.0-dev"
"dev-main": "7.0-dev"
}
},
"autoload": {
@ -5067,36 +5190,48 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0"
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
"type": "tidelift"
}
],
"time": "2023-08-31T06:24:48+00:00"
"time": "2026-02-06T04:33:26+00:00"
},
{
"name": "phpunit/php-invoker",
"version": "4.0.0",
"version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-invoker.git",
"reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7"
"reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
"reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7",
"url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
"reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"ext-pcntl": "*",
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"suggest": {
"ext-pcntl": "*"
@ -5104,7 +5239,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "4.0-dev"
"dev-main": "7.0-dev"
}
},
"autoload": {
@ -5130,40 +5265,53 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-invoker/issues",
"source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0"
"security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
"source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker",
"type": "tidelift"
}
],
"time": "2023-02-03T06:56:09+00:00"
"time": "2026-02-06T04:34:47+00:00"
},
{
"name": "phpunit/php-text-template",
"version": "3.0.1",
"version": "6.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-text-template.git",
"reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748"
"reference": "a47af19f93f76aa3368303d752aa5272ca3299f4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748",
"reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748",
"url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4",
"reference": "a47af19f93f76aa3368303d752aa5272ca3299f4",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.0-dev"
"dev-main": "6.0-dev"
}
},
"autoload": {
@ -5190,40 +5338,52 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-text-template/issues",
"security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
"source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1"
"source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template",
"type": "tidelift"
}
],
"time": "2023-08-31T14:07:24+00:00"
"time": "2026-02-06T04:36:37+00:00"
},
{
"name": "phpunit/php-timer",
"version": "6.0.0",
"version": "9.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-timer.git",
"reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d"
"reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d",
"reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d",
"url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6",
"reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "6.0-dev"
"dev-main": "9.0-dev"
}
},
"autoload": {
@ -5249,28 +5409,41 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-timer/issues",
"source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0"
"security": "https://github.com/sebastianbergmann/php-timer/security/policy",
"source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer",
"type": "tidelift"
}
],
"time": "2023-02-03T06:57:52+00:00"
"time": "2026-02-06T04:37:53+00:00"
},
{
"name": "phpunit/phpunit",
"version": "10.5.63",
"version": "13.0.5",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "33198268dad71e926626b618f3ec3966661e4d90"
"reference": "d57826e8921a534680c613924bfd921ded8047f4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
"reference": "33198268dad71e926626b618f3ec3966661e4d90",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d57826e8921a534680c613924bfd921ded8047f4",
"reference": "d57826e8921a534680c613924bfd921ded8047f4",
"shasum": ""
},
"require": {
@ -5283,26 +5456,23 @@
"myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.1",
"phpunit/php-code-coverage": "^10.1.16",
"phpunit/php-file-iterator": "^4.1.0",
"phpunit/php-invoker": "^4.0.0",
"phpunit/php-text-template": "^3.0.1",
"phpunit/php-timer": "^6.0.0",
"sebastian/cli-parser": "^2.0.1",
"sebastian/code-unit": "^2.0.0",
"sebastian/comparator": "^5.0.5",
"sebastian/diff": "^5.1.1",
"sebastian/environment": "^6.1.0",
"sebastian/exporter": "^5.1.4",
"sebastian/global-state": "^6.0.2",
"sebastian/object-enumerator": "^5.0.0",
"sebastian/recursion-context": "^5.0.1",
"sebastian/type": "^4.0.0",
"sebastian/version": "^4.0.1"
},
"suggest": {
"ext-soap": "To be able to generate mocks based on WSDL files"
"php": ">=8.4.1",
"phpunit/php-code-coverage": "^13.0.1",
"phpunit/php-file-iterator": "^7.0.0",
"phpunit/php-invoker": "^7.0.0",
"phpunit/php-text-template": "^6.0.0",
"phpunit/php-timer": "^9.0.0",
"sebastian/cli-parser": "^5.0.0",
"sebastian/comparator": "^8.0.0",
"sebastian/diff": "^8.0.0",
"sebastian/environment": "^9.0.0",
"sebastian/exporter": "^8.0.0",
"sebastian/global-state": "^9.0.0",
"sebastian/object-enumerator": "^8.0.0",
"sebastian/recursion-context": "^8.0.0",
"sebastian/type": "^7.0.0",
"sebastian/version": "^7.0.0",
"staabm/side-effects-detector": "^1.0.5"
},
"bin": [
"phpunit"
@ -5310,7 +5480,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "10.5-dev"
"dev-main": "13.0-dev"
}
},
"autoload": {
@ -5342,7 +5512,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.0.5"
},
"funding": [
{
@ -5366,7 +5536,7 @@
"type": "tidelift"
}
],
"time": "2026-01-27T05:48:37+00:00"
"time": "2026-02-18T12:40:03+00:00"
},
{
"name": "psr/container",
@ -6009,28 +6179,28 @@
},
{
"name": "sebastian/cli-parser",
"version": "2.0.1",
"version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/cli-parser.git",
"reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084"
"reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084",
"reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084",
"url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca",
"reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.0-dev"
"dev-main": "5.0-dev"
}
},
"autoload": {
@ -6054,155 +6224,59 @@
"support": {
"issues": "https://github.com/sebastianbergmann/cli-parser/issues",
"security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
"source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1"
"source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
"time": "2024-03-02T07:12:49+00:00"
},
{
"name": "sebastian/code-unit",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/code-unit.git",
"reference": "a81fee9eef0b7a76af11d121767abc44c104e503"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503",
"reference": "a81fee9eef0b7a76af11d121767abc44c104e503",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.0-dev"
}
},
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
},
{
"name": "Sebastian Bergmann",
"email": "sebastian@phpunit.de",
"role": "lead"
}
],
"description": "Collection of value objects that represent the PHP code units",
"homepage": "https://github.com/sebastianbergmann/code-unit",
"support": {
"issues": "https://github.com/sebastianbergmann/code-unit/issues",
"source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0"
},
"funding": [
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
"time": "2023-02-03T06:58:43+00:00"
},
{
"name": "sebastian/code-unit-reverse-lookup",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
"reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
"reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.0-dev"
}
},
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"name": "Sebastian Bergmann",
"email": "sebastian@phpunit.de"
"url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
"type": "tidelift"
}
],
"description": "Looks up which function or method a line of code belongs to",
"homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
"support": {
"issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
"source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
"time": "2023-02-03T06:59:15+00:00"
"time": "2026-02-06T04:39:44+00:00"
},
{
"name": "sebastian/comparator",
"version": "5.0.5",
"version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
"reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/29b232ddc29c2b114c0358c69b3084e7c3da0d58",
"reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-mbstring": "*",
"php": ">=8.1",
"sebastian/diff": "^5.0",
"sebastian/exporter": "^5.0"
"php": ">=8.4",
"sebastian/diff": "^8.0",
"sebastian/exporter": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
"phpunit/phpunit": "^13.0"
},
"suggest": {
"ext-bcmath": "For comparing BcMath\\Number objects"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "5.0-dev"
"dev-main": "8.0-dev"
}
},
"autoload": {
@ -6242,7 +6316,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
"source": "https://github.com/sebastianbergmann/comparator/tree/8.0.0"
},
"funding": [
{
@ -6262,33 +6336,33 @@
"type": "tidelift"
}
],
"time": "2026-01-24T09:25:16+00:00"
"time": "2026-02-06T04:40:39+00:00"
},
{
"name": "sebastian/complexity",
"version": "3.2.0",
"version": "6.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/complexity.git",
"reference": "68ff824baeae169ec9f2137158ee529584553799"
"reference": "c5651c795c98093480df79350cb050813fc7a2f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799",
"reference": "68ff824baeae169ec9f2137158ee529584553799",
"url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3",
"reference": "c5651c795c98093480df79350cb050813fc7a2f3",
"shasum": ""
},
"require": {
"nikic/php-parser": "^4.18 || ^5.0",
"php": ">=8.1"
"nikic/php-parser": "^5.0",
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.2-dev"
"dev-main": "6.0-dev"
}
},
"autoload": {
@ -6312,41 +6386,53 @@
"support": {
"issues": "https://github.com/sebastianbergmann/complexity/issues",
"security": "https://github.com/sebastianbergmann/complexity/security/policy",
"source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0"
"source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/complexity",
"type": "tidelift"
}
],
"time": "2023-12-21T08:37:17+00:00"
"time": "2026-02-06T04:41:32+00:00"
},
{
"name": "sebastian/diff",
"version": "5.1.1",
"version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
"reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e"
"reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e",
"reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e",
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2b6d09d7729ee87d605a439469f9dcc39be5ea3",
"reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"symfony/process": "^6.4"
"phpunit/phpunit": "^13.0",
"symfony/process": "^7.2"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "5.1-dev"
"dev-main": "8.0-dev"
}
},
"autoload": {
@ -6379,35 +6465,47 @@
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
"security": "https://github.com/sebastianbergmann/diff/security/policy",
"source": "https://github.com/sebastianbergmann/diff/tree/5.1.1"
"source": "https://github.com/sebastianbergmann/diff/tree/8.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
"type": "tidelift"
}
],
"time": "2024-03-02T07:15:17+00:00"
"time": "2026-02-06T04:42:27+00:00"
},
{
"name": "sebastian/environment",
"version": "6.1.0",
"version": "9.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
"reference": "8074dbcd93529b357029f5cc5058fd3e43666984"
"reference": "bb64d08145b021b67d5f253308a498b73ab0461e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984",
"reference": "8074dbcd93529b357029f5cc5058fd3e43666984",
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/bb64d08145b021b67d5f253308a498b73ab0461e",
"reference": "bb64d08145b021b67d5f253308a498b73ab0461e",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"suggest": {
"ext-posix": "*"
@ -6415,7 +6513,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "6.1-dev"
"dev-main": "9.0-dev"
}
},
"autoload": {
@ -6443,42 +6541,54 @@
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"security": "https://github.com/sebastianbergmann/environment/security/policy",
"source": "https://github.com/sebastianbergmann/environment/tree/6.1.0"
"source": "https://github.com/sebastianbergmann/environment/tree/9.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
"type": "tidelift"
}
],
"time": "2024-03-23T08:47:14+00:00"
"time": "2026-02-06T04:43:29+00:00"
},
{
"name": "sebastian/exporter",
"version": "5.1.4",
"version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
"reference": "0735b90f4da94969541dac1da743446e276defa6"
"reference": "dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6",
"reference": "0735b90f4da94969541dac1da743446e276defa6",
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea",
"reference": "dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=8.1",
"sebastian/recursion-context": "^5.0"
"php": ">=8.4",
"sebastian/recursion-context": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "5.1-dev"
"dev-main": "8.0-dev"
}
},
"autoload": {
@ -6521,7 +6631,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
"security": "https://github.com/sebastianbergmann/exporter/security/policy",
"source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4"
"source": "https://github.com/sebastianbergmann/exporter/tree/8.0.0"
},
"funding": [
{
@ -6541,35 +6651,35 @@
"type": "tidelift"
}
],
"time": "2025-09-24T06:09:11+00:00"
"time": "2026-02-06T04:44:28+00:00"
},
{
"name": "sebastian/global-state",
"version": "6.0.2",
"version": "9.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
"reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9"
"reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
"reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9",
"url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7",
"reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7",
"shasum": ""
},
"require": {
"php": ">=8.1",
"sebastian/object-reflector": "^3.0",
"sebastian/recursion-context": "^5.0"
"php": ">=8.4",
"sebastian/object-reflector": "^6.0",
"sebastian/recursion-context": "^8.0"
},
"require-dev": {
"ext-dom": "*",
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "6.0-dev"
"dev-main": "9.0-dev"
}
},
"autoload": {
@ -6595,41 +6705,53 @@
"support": {
"issues": "https://github.com/sebastianbergmann/global-state/issues",
"security": "https://github.com/sebastianbergmann/global-state/security/policy",
"source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2"
"source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
"type": "tidelift"
}
],
"time": "2024-03-02T07:19:19+00:00"
"time": "2026-02-06T04:45:13+00:00"
},
{
"name": "sebastian/lines-of-code",
"version": "2.0.2",
"version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
"reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0"
"reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0",
"reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0",
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471",
"reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471",
"shasum": ""
},
"require": {
"nikic/php-parser": "^4.18 || ^5.0",
"php": ">=8.1"
"nikic/php-parser": "^5.0",
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.0-dev"
"dev-main": "5.0-dev"
}
},
"autoload": {
@ -6653,42 +6775,54 @@
"support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
"security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2"
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
"type": "tidelift"
}
],
"time": "2023-12-21T08:38:20+00:00"
"time": "2026-02-06T04:45:54+00:00"
},
{
"name": "sebastian/object-enumerator",
"version": "5.0.0",
"version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-enumerator.git",
"reference": "202d0e344a580d7f7d04b3fafce6933e59dae906"
"reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906",
"reference": "202d0e344a580d7f7d04b3fafce6933e59dae906",
"url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5",
"reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5",
"shasum": ""
},
"require": {
"php": ">=8.1",
"sebastian/object-reflector": "^3.0",
"sebastian/recursion-context": "^5.0"
"php": ">=8.4",
"sebastian/object-reflector": "^6.0",
"sebastian/recursion-context": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "5.0-dev"
"dev-main": "8.0-dev"
}
},
"autoload": {
@ -6710,40 +6844,53 @@
"homepage": "https://github.com/sebastianbergmann/object-enumerator/",
"support": {
"issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
"source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0"
"security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
"source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator",
"type": "tidelift"
}
],
"time": "2023-02-03T07:08:32+00:00"
"time": "2026-02-06T04:46:36+00:00"
},
{
"name": "sebastian/object-reflector",
"version": "3.0.0",
"version": "6.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-reflector.git",
"reference": "24ed13d98130f0e7122df55d06c5c4942a577957"
"reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957",
"reference": "24ed13d98130f0e7122df55d06c5c4942a577957",
"url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200",
"reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.0-dev"
"dev-main": "6.0-dev"
}
},
"autoload": {
@ -6765,40 +6912,53 @@
"homepage": "https://github.com/sebastianbergmann/object-reflector/",
"support": {
"issues": "https://github.com/sebastianbergmann/object-reflector/issues",
"source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0"
"security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
"source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector",
"type": "tidelift"
}
],
"time": "2023-02-03T07:06:18+00:00"
"time": "2026-02-06T04:47:13+00:00"
},
{
"name": "sebastian/recursion-context",
"version": "5.0.1",
"version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/recursion-context.git",
"reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a"
"reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a",
"reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a",
"url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e",
"reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "5.0-dev"
"dev-main": "8.0-dev"
}
},
"autoload": {
@ -6829,7 +6989,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/recursion-context/issues",
"security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
"source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1"
"source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0"
},
"funding": [
{
@ -6849,32 +7009,32 @@
"type": "tidelift"
}
],
"time": "2025-08-10T07:50:56+00:00"
"time": "2026-02-06T04:51:28+00:00"
},
{
"name": "sebastian/type",
"version": "4.0.0",
"version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/type.git",
"reference": "462699a16464c3944eefc02ebdd77882bd3925bf"
"reference": "42412224607bd3931241bbd17f38e0f972f5a916"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf",
"reference": "462699a16464c3944eefc02ebdd77882bd3925bf",
"url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916",
"reference": "42412224607bd3931241bbd17f38e0f972f5a916",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
"phpunit/phpunit": "^13.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "4.0-dev"
"dev-main": "7.0-dev"
}
},
"autoload": {
@ -6897,37 +7057,50 @@
"homepage": "https://github.com/sebastianbergmann/type",
"support": {
"issues": "https://github.com/sebastianbergmann/type/issues",
"source": "https://github.com/sebastianbergmann/type/tree/4.0.0"
"security": "https://github.com/sebastianbergmann/type/security/policy",
"source": "https://github.com/sebastianbergmann/type/tree/7.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/type",
"type": "tidelift"
}
],
"time": "2023-02-03T07:10:45+00:00"
"time": "2026-02-06T04:52:09+00:00"
},
{
"name": "sebastian/version",
"version": "4.0.1",
"version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/version.git",
"reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17"
"reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17",
"reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17",
"url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b",
"reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b",
"shasum": ""
},
"require": {
"php": ">=8.1"
"php": ">=8.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "4.0-dev"
"dev-main": "7.0-dev"
}
},
"autoload": {
@ -6950,15 +7123,28 @@
"homepage": "https://github.com/sebastianbergmann/version",
"support": {
"issues": "https://github.com/sebastianbergmann/version/issues",
"source": "https://github.com/sebastianbergmann/version/tree/4.0.1"
"security": "https://github.com/sebastianbergmann/version/security/policy",
"source": "https://github.com/sebastianbergmann/version/tree/7.0.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/version",
"type": "tidelift"
}
],
"time": "2023-02-07T11:34:05+00:00"
"time": "2026-02-06T04:52:52+00:00"
},
{
"name": "sebastianfeldmann/camino",
@ -7137,48 +7323,92 @@
"time": "2026-01-26T20:59:18+00:00"
},
{
"name": "symfony/console",
"version": "v7.4.4",
"name": "staabm/side-effects-detector",
"version": "1.0.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894"
"url": "https://github.com/staabm/side-effects-detector.git",
"reference": "d8334211a140ce329c13726d4a715adbddd0a163"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
"reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
"url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
"reference": "d8334211a140ce329c13726d4a715adbddd0a163",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/string": "^7.2|^8.0"
"ext-tokenizer": "*",
"php": "^7.4 || ^8.0"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
"symfony/dotenv": "<6.4",
"symfony/event-dispatcher": "<6.4",
"symfony/lock": "<6.4",
"symfony/process": "<6.4"
"require-dev": {
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^1.12.6",
"phpunit/phpunit": "^9.6.21",
"symfony/var-dumper": "^5.4.43",
"tomasvotruba/type-coverage": "1.0.0",
"tomasvotruba/unused-public": "1.0.0"
},
"type": "library",
"autoload": {
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A static analysis tool to detect side effects in PHP code",
"keywords": [
"static analysis"
],
"support": {
"issues": "https://github.com/staabm/side-effects-detector/issues",
"source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
},
"funding": [
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/console",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b",
"reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-mbstring": "^1.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/string": "^7.4|^8.0"
},
"provide": {
"psr/log-implementation": "1.0|2.0|3.0"
},
"require-dev": {
"psr/log": "^1|^2|^3",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/lock": "^6.4|^7.0|^8.0",
"symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0"
"symfony/config": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/lock": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@ -7212,7 +7442,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.4"
"source": "https://github.com/symfony/console/tree/v8.0.4"
},
"funding": [
{
@ -7232,28 +7462,28 @@
"type": "tidelift"
}
],
"time": "2026-01-13T11:36:38+00:00"
"time": "2026-01-13T13:06:50+00:00"
},
{
"name": "symfony/event-dispatcher",
"version": "v7.4.4",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
"reference": "dc2c0eba1af673e736bb851d747d266108aea746"
"reference": "99301401da182b6cfaa4700dbe9987bb75474b47"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746",
"reference": "dc2c0eba1af673e736bb851d747d266108aea746",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47",
"reference": "99301401da182b6cfaa4700dbe9987bb75474b47",
"shasum": ""
},
"require": {
"php": ">=8.2",
"php": ">=8.4",
"symfony/event-dispatcher-contracts": "^2.5|^3"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
"symfony/security-http": "<7.4",
"symfony/service-contracts": "<2.5"
},
"provide": {
@ -7262,14 +7492,14 @@
},
"require-dev": {
"psr/log": "^1|^2|^3",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/error-handler": "^6.4|^7.0|^8.0",
"symfony/expression-language": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/error-handler": "^7.4|^8.0",
"symfony/expression-language": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/stopwatch": "^6.4|^7.0|^8.0"
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@ -7297,7 +7527,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4"
"source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4"
},
"funding": [
{
@ -7317,7 +7547,7 @@
"type": "tidelift"
}
],
"time": "2026-01-05T11:45:34+00:00"
"time": "2026-01-05T11:45:55+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@ -7397,23 +7627,23 @@
},
{
"name": "symfony/finder",
"version": "v7.4.5",
"version": "v8.0.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb"
"reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb",
"reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb",
"url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0",
"reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0",
"shasum": ""
},
"require": {
"php": ">=8.2"
"php": ">=8.4"
},
"require-dev": {
"symfony/filesystem": "^6.4|^7.0|^8.0"
"symfony/filesystem": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@ -7441,7 +7671,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v7.4.5"
"source": "https://github.com/symfony/finder/tree/v8.0.5"
},
"funding": [
{
@ -7461,24 +7691,24 @@
"type": "tidelift"
}
],
"time": "2026-01-26T15:07:59+00:00"
"time": "2026-01-26T15:08:38+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v7.4.0",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "b38026df55197f9e39a44f3215788edf83187b80"
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80",
"reference": "b38026df55197f9e39a44f3215788edf83187b80",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"shasum": ""
},
"require": {
"php": ">=8.2",
"php": ">=8.4",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
@ -7512,7 +7742,7 @@
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v7.4.0"
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
},
"funding": [
{
@ -7532,7 +7762,7 @@
"type": "tidelift"
}
],
"time": "2025-11-12T15:39:26+00:00"
"time": "2025-11-12T15:55:31+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
@ -7863,20 +8093,20 @@
},
{
"name": "symfony/process",
"version": "v7.4.5",
"version": "v8.0.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "608476f4604102976d687c483ac63a79ba18cc97"
"reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97",
"reference": "608476f4604102976d687c483ac63a79ba18cc97",
"url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674",
"reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674",
"shasum": ""
},
"require": {
"php": ">=8.2"
"php": ">=8.4"
},
"type": "library",
"autoload": {
@ -7904,7 +8134,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.4.5"
"source": "https://github.com/symfony/process/tree/v8.0.5"
},
"funding": [
{
@ -7924,7 +8154,7 @@
"type": "tidelift"
}
],
"time": "2026-01-26T15:07:59+00:00"
"time": "2026-01-26T15:08:38+00:00"
},
{
"name": "symfony/service-contracts",
@ -8015,20 +8245,20 @@
},
{
"name": "symfony/stopwatch",
"version": "v7.4.0",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
"reference": "8a24af0a2e8a872fb745047180649b8418303084"
"reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084",
"reference": "8a24af0a2e8a872fb745047180649b8418303084",
"url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942",
"reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942",
"shasum": ""
},
"require": {
"php": ">=8.2",
"php": ">=8.4",
"symfony/service-contracts": "^2.5|^3"
},
"type": "library",
@ -8057,7 +8287,7 @@
"description": "Provides a way to profile code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/stopwatch/tree/v7.4.0"
"source": "https://github.com/symfony/stopwatch/tree/v8.0.0"
},
"funding": [
{
@ -8077,39 +8307,38 @@
"type": "tidelift"
}
],
"time": "2025-08-04T07:05:15+00:00"
"time": "2025-08-04T07:36:47+00:00"
},
{
"name": "symfony/string",
"version": "v7.4.4",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f"
"reference": "758b372d6882506821ed666032e43020c4f57194"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f",
"reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f",
"url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194",
"reference": "758b372d6882506821ed666032e43020c4f57194",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-intl-grapheme": "~1.33",
"symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0"
"php": ">=8.4",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-intl-grapheme": "^1.33",
"symfony/polyfill-intl-normalizer": "^1.0",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"symfony/translation-contracts": "<2.5"
},
"require-dev": {
"symfony/emoji": "^7.1|^8.0",
"symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/intl": "^6.4|^7.0|^8.0",
"symfony/emoji": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/intl": "^7.4|^8.0",
"symfony/translation-contracts": "^2.5|^3.0",
"symfony/var-exporter": "^6.4|^7.0|^8.0"
"symfony/var-exporter": "^7.4|^8.0"
},
"type": "library",
"autoload": {
@ -8148,7 +8377,7 @@
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v7.4.4"
"source": "https://github.com/symfony/string/tree/v8.0.4"
},
"funding": [
{
@ -8168,7 +8397,7 @@
"type": "tidelift"
}
],
"time": "2026-01-12T10:54:30+00:00"
"time": "2026-01-12T12:37:40+00:00"
},
{
"name": "symplify/coding-standard",
@ -8291,23 +8520,23 @@
},
{
"name": "theseer/tokenizer",
"version": "1.3.1",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
"reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
"reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
"reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
"url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
"reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-tokenizer": "*",
"ext-xmlwriter": "*",
"php": "^7.2 || ^8.0"
"php": "^8.1"
},
"type": "library",
"autoload": {
@ -8329,7 +8558,7 @@
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
"support": {
"issues": "https://github.com/theseer/tokenizer/issues",
"source": "https://github.com/theseer/tokenizer/tree/1.3.1"
"source": "https://github.com/theseer/tokenizer/tree/2.0.1"
},
"funding": [
{
@ -8337,12 +8566,13 @@
"type": "github"
}
],
"time": "2025-11-17T20:03:58+00:00"
"time": "2025-12-08T11:19:18+00:00"
}
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"adaures/castopod-plugins-manager": 20,
"codeigniter4/tasks": 20,
"opawg/user-agents-v2-php": 20,
"yassinedoghri/podcast-feed": 20
@ -8350,7 +8580,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.1"
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"

View file

@ -4,7 +4,7 @@
# ⚠️ NOT optimized for production
# should be used only for continuous integration
#---------------------------------------------------
FROM php:8.1-fpm-alpine3.22
FROM php:8.5-fpm-alpine3.23
LABEL maintainer="Yassine Doghri <yassine@doghri.fr>"
@ -32,9 +32,8 @@ RUN \
mysqli \
&& apk del .php-ext-build-dep \
# install pnpm
&& wget -qO- https://get.pnpm.io/install.sh | ENV="~/.shrc" SHELL="$(which sh)" sh - \
&& mv ~/.local/share/pnpm/pnpm /usr/bin/pnpm \
&& rm -rf ~/.local \
&& npm install --global corepack@latest \
&& corepack enable pnpm \
# set pnpm store directory
&& pnpm config set store-dir .pnpm-store \
# set composer cache directory

Some files were not shown because too many files have changed in this diff Show more