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
1170 changed files with 35992 additions and 65291 deletions

View file

@ -3,17 +3,15 @@
"projectOwner": "adaures", "projectOwner": "adaures",
"repoType": "gitlab", "repoType": "gitlab",
"repoHost": "https://code.castopod.org", "repoHost": "https://code.castopod.org",
"files": ["README.md", "docs/src/content/docs/en/index.mdx"], "files": ["README.md"],
"imageSize": 100, "imageSize": 100,
"commit": false, "commit": false,
"contributorsPerLine": 7,
"wrapperTemplate": "\n<table class=\"all-contributors-table\">\n <tbody><%= bodyContent %> </tbody>\n<%= tableFooterContent %></table>\n\n",
"contributors": [ "contributors": [
{ {
"login": "yassinedoghri", "login": "yassinedoghri",
"name": "Yassine Doghri", "name": "Yassine Doghri",
"avatar_url": "https://code.castopod.org/uploads/-/system/user/avatar/3/avatar.png", "avatar_url": "https://avatars.githubusercontent.com/u/11021441?v=4",
"profile": "https://github.com/yassinedoghri", "profile": "https://yassinedoghri.com",
"contributions": [ "contributions": [
"code", "code",
"bug", "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", "login": "basen1982",
"name": "Andreas Olsson", "name": "Andreas Olsson",
@ -545,7 +531,7 @@
] ]
}, },
{ {
"login": "ahmed.sabouni11", "login": "ahmedsabouni",
"name": "Ahmed Sabouni", "name": "Ahmed Sabouni",
"avatar_url": "https://avatars.githubusercontent.com/u/74497842?v=4", "avatar_url": "https://avatars.githubusercontent.com/u/74497842?v=4",
"profile": "https://github.com/ahmedsabouni", "profile": "https://github.com/ahmedsabouni",
@ -564,11 +550,25 @@
"contributions": ["code"] "contributions": ["code"]
}, },
{ {
"login": "NeoluxConsulting", "login": "Dwev",
"name": "Guy Martin", "name": "Guy Martin",
"avatar_url": "https://secure.gravatar.com/avatar/6e745565356330c1e29a85d52bffdaa1?s=80&d=identicon", "avatar_url": "https://avatars.githubusercontent.com/u/46626050?v=4",
"profile": "https://code.castopod.org/NeoluxConsulting", "profile": "https://github.com/Dwev",
"contributions": ["bug", "code"] "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" "commitConvention": "none"

View file

@ -4,7 +4,7 @@
# ⚠️ NOT optimized for production # ⚠️ NOT optimized for production
# should be used only for development purposes # should be used only for development purposes
#--------------------------------------------------- #---------------------------------------------------
FROM php:8.2-fpm FROM php:8.5-fpm
LABEL maintainer="Yassine Doghri <yassine@doghri.fr>" LABEL maintainer="Yassine Doghri <yassine@doghri.fr>"
@ -12,7 +12,7 @@ LABEL maintainer="Yassine Doghri <yassine@doghri.fr>"
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Install server requirements # Install server requirements
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --no-install-recommends nodejs \ && apt-get install --yes --no-install-recommends nodejs \
# gnupg to sign commits with gpg # gnupg to sign commits with gpg
@ -47,11 +47,4 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& docker-php-ext-enable redis \ && docker-php-ext-enable redis \
# mysqli for database access # mysqli for database access
&& docker-php-ext-install mysqli \ && docker-php-ext-install mysqli \
&& docker-php-ext-enable 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

View file

@ -6,7 +6,7 @@
"service": "app", "service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"postCreateCommand": "composer install && pnpm install && pnpm run build:static && php spark migrate --all && php spark db:seed DevSeeder", "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", "postAttachCommand": "crontab .devcontainer/crontab && service cron reload",
"shutdownAction": "stopCompose", "shutdownAction": "stopCompose",
"features": { "features": {
@ -30,7 +30,17 @@
"spark": "php", "spark": "php",
"env": "dotenv", "env": "dotenv",
".rsync-filter": "diff" ".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": [ "extensions": [
"astro-build.astro-vscode", "astro-build.astro-vscode",
@ -52,7 +62,8 @@
"stylelint.vscode-stylelint", "stylelint.vscode-stylelint",
"unifiedjs.vscode-mdx", "unifiedjs.vscode-mdx",
"wayou.vscode-todo-highlight", "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: services:
app: app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports:
- 8080:8080
volumes: volumes:
- ../..:/workspaces:cached - ../..:/workspaces:cached
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
environment: 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 CI_ENVIRONMENT: development
vite_environment: development vite_environment: development
app_forceGlobalSecureRequests: 0 #false app_forceGlobalSecureRequests: 0 #false
app_baseURL: http://localhost:8080/ app_baseURL: http://localhost:${APP_PORT:-8080}/
media_baseURL: http://localhost:8080/ media_baseURL: http://localhost:${APP_PORT:-8080}/
admin_gateway: cp-admin admin_gateway: cp-admin
auth_gateway: cp-auth auth_gateway: cp-auth
analytics_salt: dev_analytics_salt analytics_salt: dev_analytics_salt
@ -29,16 +28,10 @@ services:
email_SMTPHost: mailpit email_SMTPHost: mailpit
email_SMTPUser: castopod email_SMTPUser: castopod
email_SMTPPass: castopod email_SMTPPass: castopod
email_SMTPPort: 1025 email_SMTPPort: ${MAILPIT_SMTP_PORT:-1025}
depends_on: depends_on:
- redis
- mariadb - mariadb
redis:
image: redis:alpine
volumes:
- redis:/data
mariadb: mariadb:
image: mariadb:10.2 image: mariadb:10.2
volumes: volumes:
@ -69,8 +62,8 @@ services:
volumes: volumes:
- mailpit:/data - mailpit:/data
ports: ports:
- 8025:8025 - ${MAILPIT_WEBUI_PORT:-8025}:8025
- 1025:1025 - ${MAILPIT_SMTP_PORT:-1025}:1025
environment: environment:
MP_MAX_MESSAGES: 5000 MP_MAX_MESSAGES: 5000
MP_DATA_FILE: /data/mailpit.db MP_DATA_FILE: /data/mailpit.db
@ -78,7 +71,6 @@ services:
MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes: volumes:
redis:
mariadb: mariadb:
phpmyadmin: phpmyadmin:
mailpit: 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

68
.dockerignore Normal file
View file

@ -0,0 +1,68 @@
.env
.git/
node_modules/
vendor/
build/
docs/
scripts/
tests/
#-------------------------
# Temporary Files
#-------------------------
writable/cache/*
!writable/cache/index.html
writable/logs/*
!writable/logs/index.html
writable/session/*
!writable/session/index.html
writable/temp/*
!writable/temp/index.html
writable/uploads/*
!writable/uploads/index.html
writable/debugbar/*
!writable/debugbar/index.html
# public folder
public/*
!public/media
!public/.htaccess
!public/favicon.ico
!public/icon*
!public/castopod-banner*
!public/castopod-avatar*
!public/index.php
!public/robots.txt
!public/.well-known
!public/.well-known/GDPR.yml
public/assets/*
!public/assets/index.html
# public media folder
!public/media/podcasts
!public/media/persons
!public/media/site
public/media/podcasts/*
!public/media/podcasts/index.html
public/media/persons/*
!public/media/persons/index.html
public/media/site/*
!public/media/site/index.html
# Generated files
modules/Admin/Language/*/PersonsTaxonomy.php
# Castopod bundle & packages
castopod/
castopod-*.zip
castopod-*.tar.gz

View file

@ -1,18 +0,0 @@
{
"env": {
"browser": true,
"es2020": true
},
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module"
},
"rules": {}
}

36
.gitignore vendored
View file

@ -67,7 +67,7 @@ writable/uploads/*
!writable/uploads/index.html !writable/uploads/index.html
writable/debugbar/* writable/debugbar/*
!writable/debugbar/.gitkeep !writable/debugbar/index.html
php_errors.log php_errors.log
@ -107,15 +107,15 @@ _modules/*
.idea/ .idea/
*.iml *.iml
# Netbeans # NetBeans
nbproject/ /nbproject/
build/ /build/
nbbuild/ /nbbuild/
dist/ /dist/
nbdist/ /nbdist/
nbactions.xml /nbactions.xml
nb-configuration.xml /nb-configuration.xml
.nb-gradle/ /.nb-gradle/
# Sublime Text # Sublime Text
*.tmlanguage.cache *.tmlanguage.cache
@ -128,6 +128,7 @@ nb-configuration.xml
# Visual Studio Code # Visual Studio Code
.vscode/ .vscode/
.history/
tmp/ tmp/
/results/ /results/
@ -174,16 +175,13 @@ public/media/site/*
# Generated files # Generated files
modules/Admin/Language/*/PersonsTaxonomy.php modules/Admin/Language/*/PersonsTaxonomy.php
#-------------------------
# Docker volumes
#-------------------------
mariadb
phpmyadmin
sessions
data
# Castopod bundle & packages # Castopod bundle & packages
castopod/ castopod/
castopod-*.zip castopod-*.zip
castopod-*.tar.gz 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 image: code.castopod.org:5050/adaures/castopod:ci-php8.5
stages: stages:
- prepare - prepare
@ -23,6 +23,10 @@ php-dependencies:
expire_in: 30 mins expire_in: 30 mins
paths: paths:
- vendor/ - vendor/
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
js-dependencies: js-dependencies:
stage: prepare stage: prepare
@ -39,6 +43,10 @@ js-dependencies:
expire_in: 30 mins expire_in: 30 mins
paths: paths:
- node_modules/ - node_modules/
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
lint-commit-msg: lint-commit-msg:
stage: quality stage: quality
@ -48,11 +56,10 @@ lint-commit-msg:
- ./scripts/lint-commit-msg.sh - ./scripts/lint-commit-msg.sh
dependencies: dependencies:
- js-dependencies - js-dependencies
only: rules:
- develop - if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
- main when: never
- beta - if: $CI_COMMIT_BRANCH =~ /^(develop|main|alpha|beta|next)$/
- alpha
lint-php: lint-php:
stage: quality stage: quality
@ -65,34 +72,46 @@ lint-php:
- vendor/bin/rector process --dry-run --ansi - vendor/bin/rector process --dry-run --ansi
dependencies: dependencies:
- php-dependencies - php-dependencies
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
lint-js: lint-js:
stage: quality stage: quality
script: script:
- pnpm run prettier - pnpm run format
- pnpm run typecheck - pnpm run typecheck
- pnpm run lint - pnpm run lint
- pnpm run lint:css - pnpm run lint:css
dependencies: dependencies:
- js-dependencies - js-dependencies
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
tests: tests:
stage: quality stage: quality
services: services:
- mariadb:10.2 - mariadb:10.11
variables: variables:
MYSQL_ROOT_PASSWORD: "R00Tp4ssW0RD" MYSQL_ROOT_PASSWORD: "R00Tp4ssW0RD"
MYSQL_DATABASE: "test" MYSQL_DATABASE: "test"
MYSQL_USER: "castopod" MYSQL_USER: "castopod"
MYSQL_PASSWORD: "castopod" MYSQL_PASSWORD: "castopod"
script: script:
- echo "SHOW DATABASES;" | mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host=mariadb "$MYSQL_DATABASE" - echo "SHOW DATABASES;" | mariadb --user=root --password="$MYSQL_ROOT_PASSWORD" --host=mariadb "$MYSQL_DATABASE" --skip_ssl
# run phpunit without code coverage # run phpunit without code coverage
# TODO: add code coverage # TODO: add code coverage
- vendor/bin/phpunit --no-coverage - vendor/bin/phpunit --no-coverage
dependencies: dependencies:
- php-dependencies - php-dependencies
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
bundle: bundle:
stage: bundle stage: bundle
@ -113,13 +132,12 @@ bundle:
name: "castopod-${CI_COMMIT_REF_SLUG}_${CI_COMMIT_SHORT_SHA}" name: "castopod-${CI_COMMIT_REF_SLUG}_${CI_COMMIT_SHORT_SHA}"
paths: paths:
- castopod - castopod
only: rules:
variables: - if: $CI_PROJECT_NAMESPACE != "adaures"
- $CI_PROJECT_NAMESPACE == "adaures" when: never
except: - if: $CI_COMMIT_BRANCH =~ /^(main|alpha|beta|next)$/ || $CI_COMMIT_TAG
- main when: never
- beta - when: on_success
- alpha
release: release:
stage: release stage: release
@ -143,38 +161,38 @@ release:
artifacts: artifacts:
paths: paths:
- castopod - castopod
- CP_VERSION.env rules:
only: - if: $CI_PROJECT_NAMESPACE != "adaures"
- main when: never
- beta - if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
- alpha when: never
- if: $CI_COMMIT_BRANCH =~ /^(main|alpha|beta|next)$/
website: website:
stage: deploy stage: deploy
trigger: adaures/castopod.org trigger: adaures/castopod.org
only: rules:
- main - if: $CI_PROJECT_NAMESPACE != "adaures"
- beta when: never
- alpha - if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/ && $CI_COMMIT_TAG
documentation: documentation:
stage: deploy stage: deploy
trigger: trigger:
include: docs/.gitlab-ci.yml include: docs/.gitlab-ci.yml
strategy: depend strategy: depend
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
docker: docker:
stage: build stage: build
trigger: trigger:
include: docker/production/.gitlab-ci.yml include: docker/production/.gitlab-ci.yml
strategy: depend strategy: depend
variables: rules:
PARENT_PIPELINE_ID: $CI_PIPELINE_ID - if: $CI_PROJECT_NAMESPACE != "adaures"
only: when: never
refs: - if: $CI_COMMIT_BRANCH == "develop"
- develop - if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/ && $CI_COMMIT_TAG
- main
- beta
- alpha
variables:
- $CI_PROJECT_NAMESPACE == "adaures"

View file

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

View file

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

View file

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

View file

@ -8,11 +8,74 @@
{ {
"name": "beta", "name": "beta",
"prerelease": true "prerelease": true
},
{
"name": "next",
"prerelease": true
} }
], ],
"plugins": [ "plugins": [
[
"@semantic-release/commit-analyzer", "@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{
"type": "docs",
"scope": "README",
"release": "patch"
},
{
"type": "refactor",
"scope": "core-*",
"release": "minor"
},
{
"type": "refactor",
"release": "patch"
}
],
"parserOpts": {
"noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES", "BREAKING"]
}
}
],
[
"@semantic-release/release-notes-generator", "@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"parserOpts": {
"noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES", "BREAKING"]
},
"presetConfig": {
"types": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "chore",
"section": "Internal",
"hidden": false
},
{
"type": "refactor",
"section": "Internal",
"hidden": false
},
{
"type": "perf",
"section": "Internal",
"hidden": false
}
]
}
}
],
"@semantic-release/changelog", "@semantic-release/changelog",
[ [
"@semantic-release/exec", "@semantic-release/exec",
@ -30,7 +93,8 @@
"package.json", "package.json",
"package-lock.json", "package-lock.json",
"CHANGELOG.md" "CHANGELOG.md"
] ],
"message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
} }
], ],
[ [

View file

@ -1,9 +1,10 @@
# rsync filter rules to copy required files for Castopod's bundle # rsync filter rules to copy required files for Castopod's bundle
+ app/Resources/icons/*** + resources/icons/***
- app/Resources/** + resources/
+ app/*** + app/***
+ modules/*** + modules/***
+ plugins/***
+ public/*** + public/***
+ themes/*** + themes/***
+ vendor/*** + vendor/***

View file

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

View file

@ -1,3 +1,185 @@
## [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](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
([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
([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
([63c763f](https://code.castopod.org/adaures/castopod/commit/63c763f941195b3758c4b91acd8c350a5e7bb9c2)),
closes [#510](https://code.castopod.org/adaures/castopod/issues/510)
- 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
([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))
### Internal
- **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))
# [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](https://code.castopod.org/adaures/castopod/commit/0ba0a25b11bd67aeeb47a8179b72152dfd4a36da))
- broken icon call in frontend default pages template
([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
([5d35524](https://code.castopod.org/adaures/castopod/commit/5d355248753be24e3cf324144ff076f2fc23be88)),
closes [#500](https://code.castopod.org/adaures/castopod/issues/500)
### 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
- add missing php-icons config file to bundle
([56612f0](https://code.castopod.org/adaures/castopod/commit/56612f0c762aa2d98e3c8c77fba88ffdf6f46a44))
- **docs:** add base to og image using env variable
([fe67659](https://code.castopod.org/adaures/castopod/commit/fe676590f23a33bdbe8905d234760923c029e350))
- **import:** rewrite download_file helper to output curl response directly to
file
([eb7ad2f](https://code.castopod.org/adaures/castopod/commit/eb7ad2f7e1c0137f222f47e47062887de42c4824))
- include app/Resources/icons folder to bundle
([3fd5efc](https://code.castopod.org/adaures/castopod/commit/3fd5efc7956977acc19e53182f25b12813964a7d))
- **platforms:** add platforms service + reduce memory consumption when
rendering platform cards
([fe73e9f](https://code.castopod.org/adaures/castopod/commit/fe73e9fae9ea5d5ce946680aec194308bb2e620c))
- set owner email visibility when editing podcast
([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) # [1.11.0](https://code.castopod.org/adaures/castopod/compare/v1.10.5...v1.11.0) (4/17/2024)
### Bug Fixes ### Bug Fixes

View file

@ -1,128 +1,162 @@
# Contributor Covenant Code of Conduct # Contributor Covenant 3.0 Code of Conduct
## Our Pledge ## Our Pledge
We as members, contributors, and leaders pledge to make participation in our We pledge to make our community welcoming, safe, and equitable for all.
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 act and interact in ways that contribute to an open, welcoming, We are committed to fostering an environment that respects and promotes the
diverse, inclusive, and healthy community. 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 While acknowledging differences in social norms, we all strive to meet our
community include: 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 With these considerations in mind, we agree to behave mindfully toward each
- Being respectful of differing opinions, viewpoints, and experiences other and act in ways that center our shared values, including:
- 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
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 ## Restricted Behaviors
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
## 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 1. **Harassment.** Violating explicitly expressed boundaries or engaging in
acceptable behavior and will take appropriate and fair corrective action in unnecessary personal attention after any clear request to stop.
response to any behavior that they deem inappropriate, threatening, offensive, 2. **Character attacks.** Making insulting, demeaning, or pejorative comments
or harmful. 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 ### Other Restrictions
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation 1. **Misleading identity.** Impersonating someone else for any reason, or
decisions when appropriate. 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 ## Scope
This Code of Conduct applies within all community spaces, and also applies when This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces. an individual is officially representing the community in public or other
Examples of representing our community include using an official e-mail address, spaces. Examples of representing our community include using an official email
posting via an official social media account, or acting as an appointed address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. 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 ## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], This Code of Conduct is adapted from the Contributor Covenant, version 3.0,
version 2.0, available at permanently available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
Community Impact Guidelines were inspired by Contributor Covenant is stewarded by the Organization for Ethical Source and
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 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 Contributor Covenant, see the FAQ at
[https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq).
For answers to common questions about this code of conduct, see the FAQ at Translations are provided at
https://www.contributor-covenant.org/faq. Translations are available at [https://www.contributor-covenant.org/translations](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 Castopod is a web app based on the `php` framework
[CodeIgniter 4](https://codeigniter.com). [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 `docker-compose.yml` and `Dockerfile` are included in the project's root folder
to help you kickstart your contribution. to help you kickstart your contribution.
@ -16,9 +16,9 @@ to help you kickstart your contribution.
### 1. Pre-requisites ### 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 ```bash
git clone https://code.castopod.org/adaures/castopod.git 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) > [CodeIgniter4 User Guide](https://codeigniter.com/user_guide/index.html)
> for more info. > 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` `Settings` > `Resources` > `File Sharing`
### 2. (recommended) Develop inside the app container with VSCode ### 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 > The VSCode window will reload inside the dev container. Expect several
> minutes during first load as it is building all necessary services. > 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 During development, you will have to start [Vite](https://vitejs.dev)'s dev
server for compiling the typescript code and styles: server for compiling the typescript code and styles:
@ -105,7 +105,7 @@ required services will be loaded automagically! 🪄
pnpm run dev 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: using the following commands:
```bash ```bash
@ -146,12 +146,10 @@ To see your changes, go to:
- `http://localhost:8080/` for the Castopod website - `http://localhost:8080/` for the Castopod website
- `http://localhost:8080/cp-admin` for the Castopod admin: - `http://localhost:8080/cp-admin` for the Castopod admin:
- email: **admin@castopod.local** - email: **admin@castopod.local**
- password: **castopod** - password: **castopod**
- `http://localhost:8888/` for the phpmyadmin interface: - `http://localhost:8888/` for the phpmyadmin interface:
- username: **castopod** - username: **castopod**
- password: **castopod** - password: **castopod**
@ -159,9 +157,9 @@ To see your changes, go to:
You do not wish to use the VSCode devcontainer? No problem! 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 ```bash
# starts all services declared in docker-compose.yml file # starts all services declared in docker-compose.yml file
@ -225,14 +223,14 @@ You do not wish to use the VSCode devcontainer? No problem!
> For more info, check out the > For more info, check out the
> [Composer documentation](https://getcomposer.org/doc/). > [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 ```bash
pnpm install pnpm install
``` ```
> [!NOTE] > [!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 > check the `package.json` and `pnpm-lock.yaml` files to download the
> packages with the right versions. The dependencies will live under the > packages with the right versions. The dependencies will live under the
> `node_module` folder. For more info, check out the > `node_module` folder. For more info, check out the
@ -251,7 +249,7 @@ You do not wish to use the VSCode devcontainer? No problem!
> [!NOTE] > [!NOTE]
> The static assets generated live under the `public/assets` folder, it > 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 ### Initialize and populate database
@ -293,8 +291,7 @@ You do not wish to use the VSCode devcontainer? No problem!
php spark db:seed DevSuperadminSeeder 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: - Populate with fake podcast analytics:
```bash ```bash
@ -315,13 +312,13 @@ You do not wish to use the VSCode devcontainer? No problem!
docker-compose logs --tail 50 --follow --timestamps app 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 ```bash
docker exec -it castopod_redis redis-cli docker exec -it castopod_redis redis-cli
``` ```
- Monitor the redis container: - Monitor the Redis container:
```bash ```bash
docker-compose logs --tail 50 --follow --timestamps redis docker-compose logs --tail 50 --follow --timestamps redis
@ -357,10 +354,37 @@ docker-compose down
docker-compose build app 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 [docker-compose](https://docs.docker.com/compose/reference/) documentations for
more insights. 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 ## Known issues
### Allocation failed - JavaScript heap out of memory ### Allocation failed - JavaScript heap out of memory
@ -399,7 +423,7 @@ You may use Linux user namespaces to fix this on your machine:
username:100000:65536 username:100000:65536
``` ```
3. Restart docker: 3. Restart Docker:
```bash ```bash
sudo systemctl restart docker sudo systemctl restart docker

View file

@ -71,8 +71,8 @@ experience the problem? What would you expect to be the outcome? All these
details will help people to fix any potential bugs. details will help people to fix any potential bugs.
> [!NOTE] > [!NOTE]
> [Issue templates](https://docs.gitlab.com/ee/user/project/description_templates.html#using-the-templates) > [Issue templates](https://docs.gitlab.com/ee/user/project/description_templates.html#using-the-templates) have
> have been created for this project. You may use them to help you follow those > been created for this project. You may use them to help you follow those
> guidelines. > guidelines.
## Feature requests ## Feature requests
@ -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 Adhering to the following process is the best way to get your work included in
the project: the project:
1. [Fork](https://docs.gitlab.com/ee/gitlab-basics/fork-project.html) the 1. [Fork](https://docs.gitlab.com/ee/user/project/repository/forking_workflow.html)
project, clone your fork, and configure the remotes: the project, clone your fork, and configure the remotes:
```bash ```bash
# Clone your fork of the repo into the current directory # 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)) ([Open Font License](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL))
- [RemixIcon](https://remixicon.com/) - [RemixIcon](https://remixicon.com/)
([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License)) ([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)) ([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) - [OPAWG/podcast-rss-useragents](https://github.com/opawg/podcast-rss-useragents)
([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([by Open Podcast Analytics Working Group](https://github.com/opawg))
([MIT license](https://github.com/opawg/podcast-rss-useragents/blob/master/LICENSE)) ([MIT license](https://github.com/opawg/podcast-rss-useragents/blob/master/LICENSE))

View file

@ -1,7 +1,7 @@
<div align="center"> <div align="center">
<h1> <h1>
<a href="https://castopod.org/"> <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> </a>
</h1> </h1>
</div> </div>
@ -46,18 +46,17 @@ Thanks goes to these wonderful people
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start --> <!-- prettier-ignore-start -->
<!-- markdownlint-disable --> <!-- markdownlint-disable -->
<table>
<table class="all-contributors-table">
<tbody> <tbody>
<tr> <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://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://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://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://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://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> <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> <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://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> <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/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/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> <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> <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/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> <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/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/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> <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> <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/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> <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://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://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> <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> <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://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> <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/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/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> <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> <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/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> <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://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://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> <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> <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/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/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/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/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://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> <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://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> </tr>
</tbody> </tbody>
</table> </table>
<!-- markdownlint-restore --> <!-- markdownlint-restore -->
<!-- prettier-ignore-end --> <!-- prettier-ignore-end -->
@ -143,7 +141,7 @@ Alternatively, you can follow us on social media platforms to get news about
Castopod: Castopod:
- [podlibre.social](https://podlibre.social/@Castopod) (Mastodon instance) - [podlibre.social](https://podlibre.social/@Castopod) (Mastodon instance)
- [Twitter](https://twitter.com/castopod) - [Bluesky](https://bsky.app/profile/castopod.org)
- [LinkedIn](https://linkedin.com/company/castopod) - [LinkedIn](https://linkedin.com/company/castopod)
- [Facebook](https://www.facebook.com/castopod) - [Facebook](https://www.facebook.com/castopod)
@ -157,10 +155,10 @@ backers. If you'd like to help, please consider
<tbody> <tbody>
<tr> <tr>
<td align="center"> <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>
<td align="center"> <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> </td>
</tr> </tr>
</tbody> </tbody>

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Commands;
use App\Models\EpisodeModel;
use CodeIgniter\CLI\BaseCommand;
class EpisodesComputeDownloads extends BaseCommand
{
/**
* The Command's Group
*
* @var string
*/
protected $group = 'Episodes';
/**
* The Command's Name
*
* @var string
*/
protected $name = 'episodes:compute-downloads';
/**
* The Command's Description
*
* @var string
*/
protected $description = "Calculates all episodes downloads and stores results in episodes' downloads_count field.";
/**
* Actually execute a command.
*/
public function run(array $params): void
{
$episodesModel = new EpisodeModel();
$query = $episodesModel->builder()
->select('episodes.id as id, IFNULL(SUM(ape.hits),0) as downloads_count')
->join('analytics_podcasts_by_episode ape', 'episodes.id=ape.episode_id', 'left')
->groupBy('episodes.id');
$episodeModel2 = new EpisodeModel();
$episodeModel2->builder()
->setQueryAsData($query)
->onConstraint('id')
->updateBatch();
}
}

View file

@ -37,7 +37,7 @@ if (! function_exists('view')) {
$renderer = single_service('renderer', $path); $renderer = single_service('renderer', $path);
$saveData = config('View') $saveData = config('View')
->saveData; ->saveData;
if (array_key_exists('saveData', $options)) { if (array_key_exists('saveData', $options)) {
$saveData = (bool) $options['saveData']; $saveData = (bool) $options['saveData'];

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Config; namespace Config;
use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\BaseConfig;
use Override;
class App extends BaseConfig class App extends BaseConfig
{ {
@ -192,9 +193,9 @@ class App extends BaseConfig
* '192.168.5.0/24' => 'X-Real-IP', * '192.168.5.0/24' => 'X-Real-IP',
* ] * ]
* *
* @var array<string, string> * @var array<string, string>|string
*/ */
public array $proxyIPs = []; public $proxyIPs = [];
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
@ -249,4 +250,37 @@ class App extends BaseConfig
public ?int $bandwidthLimit = null; public ?int $bandwidthLimit = null;
public ?string $legalNoticeURL = null; public ?string $legalNoticeURL = null;
/**
* AuthToken Config Constructor
*/
public function __construct()
{
parent::__construct();
if (is_string($this->proxyIPs)) {
$array = json_decode($this->proxyIPs, true);
if (is_array($array)) {
$this->proxyIPs = $array;
}
}
}
/**
* Override parent initEnvValue() to allow for direct setting to array properties values from ENV
*
* In order to set array properties via ENV vars we need to set the property to a string value first.
*
* @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
if ($name === 'proxyIPs' && $this->getEnvValue($name, $prefix, $shortPrefix) !== null) {
$property = '';
}
parent::initEnvValue($property, $name, $prefix, $shortPrefix);
}
} }

View file

@ -16,8 +16,6 @@ use CodeIgniter\Config\AutoloadConfig;
* *
* NOTE: If you use an identical key in $psr4 or $classmap, then * NOTE: If you use an identical key in $psr4 or $classmap, then
* the values in this file will overwrite the framework's values. * the values in this file will overwrite the framework's values.
*
* @immutable
*/ */
class Autoload extends AutoloadConfig class Autoload extends AutoloadConfig
{ {
@ -50,6 +48,7 @@ class Autoload extends AutoloadConfig
'Modules\Media' => ROOTPATH . 'modules/Media/', 'Modules\Media' => ROOTPATH . 'modules/Media/',
'Modules\MediaClipper' => ROOTPATH . 'modules/MediaClipper/', 'Modules\MediaClipper' => ROOTPATH . 'modules/MediaClipper/',
'Modules\Platforms' => ROOTPATH . 'modules/Platforms/', 'Modules\Platforms' => ROOTPATH . 'modules/Platforms/',
'Modules\Plugins' => ROOTPATH . 'modules/Plugins/',
'Modules\PodcastImport' => ROOTPATH . 'modules/PodcastImport/', 'Modules\PodcastImport' => ROOTPATH . 'modules/PodcastImport/',
'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/', 'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/',
'Modules\Update' => ROOTPATH . 'modules/Update/', 'Modules\Update' => ROOTPATH . 'modules/Update/',
@ -57,7 +56,6 @@ class Autoload extends AutoloadConfig
'Themes' => ROOTPATH . 'themes', 'Themes' => ROOTPATH . 'themes',
'ViewComponents' => APPPATH . 'Libraries/ViewComponents/', 'ViewComponents' => APPPATH . 'Libraries/ViewComponents/',
'ViewThemes' => APPPATH . 'Libraries/ViewThemes/', 'ViewThemes' => APPPATH . 'Libraries/ViewThemes/',
'Vite' => APPPATH . 'Libraries/Vite/',
]; ];
/** /**
@ -95,7 +93,7 @@ class Autoload extends AutoloadConfig
* *
* @var list<string> * @var list<string>
*/ */
public $files = [APPPATH . 'Libraries/ViewComponents/Helpers/view_components_helper.php']; public $files = [];
/** /**
* ------------------------------------------------------------------- * -------------------------------------------------------------------
@ -108,5 +106,5 @@ class Autoload extends AutoloadConfig
* *
* @var list<string> * @var list<string>
*/ */
public $helpers = ['auth', 'setting', 'icons']; public $helpers = ['auth', 'setting', 'plugins'];
} }

View file

@ -8,6 +8,19 @@ use CodeIgniter\Config\BaseConfig;
class CURLRequest extends 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 * CURLRequest Share Options

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Config; namespace Config;
use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Cache\Handlers\ApcuHandler;
use CodeIgniter\Cache\Handlers\DummyHandler; use CodeIgniter\Cache\Handlers\DummyHandler;
use CodeIgniter\Cache\Handlers\FileHandler; use CodeIgniter\Cache\Handlers\FileHandler;
use CodeIgniter\Cache\Handlers\MemcachedHandler; use CodeIgniter\Cache\Handlers\MemcachedHandler;
@ -36,18 +37,6 @@ class Cache extends BaseConfig
*/ */
public string $backupHandler = 'dummy'; public string $backupHandler = 'dummy';
/**
* --------------------------------------------------------------------------
* Cache Directory Path
* --------------------------------------------------------------------------
*
* The path to where cache files should be stored, if using a file-based
* system.
*
* @deprecated Use the driver-specific variant under $file
*/
public string $storePath = WRITEPATH . 'cache/';
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
* Key Prefix * Key Prefix
@ -88,10 +77,11 @@ class Cache extends BaseConfig
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
* File settings * File settings
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
*
* Your file storage preferences can be specified below, if you are using * Your file storage preferences can be specified below, if you are using
* the File driver. * the File driver.
* *
* @var array<string, string|int|null> * @var array{storePath?: string, mode?: int}
*/ */
public array $file = [ public array $file = [
'storePath' => WRITEPATH . 'cache/', 'storePath' => WRITEPATH . 'cache/',
@ -102,12 +92,13 @@ class Cache extends BaseConfig
* ------------------------------------------------------------------------- * -------------------------------------------------------------------------
* Memcached settings * Memcached settings
* ------------------------------------------------------------------------- * -------------------------------------------------------------------------
*
* Your Memcached servers can be specified below, if you are using * Your Memcached servers can be specified below, if you are using
* the Memcached drivers. * the Memcached drivers.
* *
* @see https://codeigniter.com/user_guide/libraries/caching.html#memcached * @see https://codeigniter.com/user_guide/libraries/caching.html#memcached
* *
* @var array<string, string|int|bool> * @var array{host?: string, port?: int, weight?: int, raw?: bool}
*/ */
public array $memcached = [ public array $memcached = [
'host' => '127.0.0.1', 'host' => '127.0.0.1',
@ -123,13 +114,23 @@ class Cache extends BaseConfig
* Your Redis server can be specified below, if you are using * Your Redis server can be specified below, if you are using
* the Redis or Predis drivers. * the Redis or Predis drivers.
* *
* @var array<string, string|int|null> * @var array{
* host?: string,
* password?: string|null,
* port?: int,
* timeout?: int,
* async?: bool,
* persistent?: bool,
* database?: int
* }
*/ */
public array $redis = [ public array $redis = [
'host' => '127.0.0.1', 'host' => '127.0.0.1',
'password' => null, 'password' => null,
'port' => 6379, 'port' => 6379,
'timeout' => 0, 'timeout' => 0,
'async' => false, // specific to Predis and ignored by the native Redis extension
'persistent' => false,
'database' => 0, 'database' => 0,
]; ];
@ -144,6 +145,7 @@ class Cache extends BaseConfig
* @var array<string, class-string<CacheInterface>> * @var array<string, class-string<CacheInterface>>
*/ */
public array $validHandlers = [ public array $validHandlers = [
'apcu' => ApcuHandler::class,
'dummy' => DummyHandler::class, 'dummy' => DummyHandler::class,
'file' => FileHandler::class, 'file' => FileHandler::class,
'memcached' => MemcachedHandler::class, 'memcached' => MemcachedHandler::class,
@ -170,4 +172,28 @@ class Cache extends BaseConfig
* @var bool|list<string> * @var bool|list<string>
*/ */
public $cacheQueryString = false; 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. | NOTE: this constant is updated upon release with Continuous Integration.
*/ */
defined('CP_VERSION') || define('CP_VERSION', '1.11.0'); defined('CP_VERSION') || define('CP_VERSION', '2.0.0-next.3');
/* /*
| -------------------------------------------------------------------- | --------------------------------------------------------------------
@ -24,10 +24,23 @@ defined('CP_VERSION') || define('CP_VERSION', '1.11.0');
| classes should use. | classes should use.
| |
| NOTE: changing this will require manually modifying the | 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'); 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 | Composer Path
@ -91,18 +104,3 @@ defined('EXIT_USER_INPUT') || define('EXIT_USER_INPUT', 7); // invalid user inpu
defined('EXIT_DATABASE') || define('EXIT_DATABASE', 8); // database error defined('EXIT_DATABASE') || define('EXIT_DATABASE', 8); // database error
defined('EXIT__AUTO_MIN') || define('EXIT__AUTO_MIN', 9); // lowest automatically-assigned error code defined('EXIT__AUTO_MIN') || define('EXIT__AUTO_MIN', 9); // lowest automatically-assigned error code
defined('EXIT__AUTO_MAX') || define('EXIT__AUTO_MAX', 125); // highest automatically-assigned error code defined('EXIT__AUTO_MAX') || define('EXIT__AUTO_MAX', 125); // highest automatically-assigned error code
/**
* @deprecated Use \CodeIgniter\Events\Events::PRIORITY_LOW instead.
*/
define('EVENT_PRIORITY_LOW', 200);
/**
* @deprecated Use \CodeIgniter\Events\Events::PRIORITY_NORMAL instead.
*/
define('EVENT_PRIORITY_NORMAL', 100);
/**
* @deprecated Use \CodeIgniter\Events\Events::PRIORITY_HIGH instead.
*/
define('EVENT_PRIORITY_HIGH', 10);

View file

@ -26,14 +26,24 @@ class ContentSecurityPolicy extends BaseConfig
*/ */
public ?string $reportURI = null; 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 * 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. * numbers of old URLs that need to be rewritten.
*/ */
public bool $upgradeInsecureRequests = false; 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 * @var list<string>|string|null
*/ */
@ -46,6 +56,21 @@ class ContentSecurityPolicy extends BaseConfig
*/ */
public string | array $scriptSrc = 'self'; 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. * Lists allowed stylesheets' URLs.
* *
@ -53,6 +78,21 @@ class ContentSecurityPolicy extends BaseConfig
*/ */
public string | array $styleSrc = 'self'; 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. * Defines the origins from which images can be loaded.
* *
@ -132,6 +172,11 @@ class ContentSecurityPolicy extends BaseConfig
*/ */
public string | array | null $manifestSrc = null; public string | array | null $manifestSrc = null;
/**
* @var list<string>|string
*/
public array|string $workerSrc = [];
/** /**
* Limits the kinds of plugins a page may invoke. * Limits the kinds of plugins a page may invoke.
* *
@ -147,17 +192,17 @@ class ContentSecurityPolicy extends BaseConfig
public string | array | null $sandbox = null; public string | array | null $sandbox = null;
/** /**
* Nonce tag for style * Nonce placeholder for style tags.
*/ */
public string $styleNonceTag = '{csp-style-nonce}'; public string $styleNonceTag = '{csp-style-nonce}';
/** /**
* Nonce tag for script * Nonce placeholder for script tags.
*/ */
public string $scriptNonceTag = '{csp-script-nonce}'; public string $scriptNonceTag = '{csp-script-nonce}';
/** /**
* Replace nonce tag automatically * Replace nonce tag automatically?
*/ */
public bool $autoNonce = true; public bool $autoNonce = true;
} }

View file

@ -85,7 +85,7 @@ class Cookie extends BaseConfig
* (empty string) means default SameSite attribute set by browsers (`Lax`) * (empty string) means default SameSite attribute set by browsers (`Lax`)
* will be set on cookies. If set to `None`, `$secure` must also be set. * will be set on cookies. If set to `None`, `$secure` must also be set.
* *
* @phpstan-var 'None'|'Lax'|'Strict'|'' * @var ''|'Lax'|'None'|'Strict'
*/ */
public string $samesite = 'Lax'; public string $samesite = 'Lax';

View file

@ -45,6 +45,7 @@ class Database extends Config
'failover' => [], 'failover' => [],
'port' => 3306, 'port' => 3306,
'numberNative' => false, 'numberNative' => false,
'foundRows' => false,
'dateFormat' => [ 'dateFormat' => [
'date' => 'Y-m-d', 'date' => 'Y-m-d',
'datetime' => 'Y-m-d H:i:s', 'datetime' => 'Y-m-d H:i:s',
@ -78,6 +79,7 @@ class Database extends Config
'port' => 3306, 'port' => 3306,
'foreignKeys' => true, 'foreignKeys' => true,
'busyTimeout' => 1000, 'busyTimeout' => 1000,
'synchronous' => null,
'dateFormat' => [ 'dateFormat' => [
'date' => 'Y-m-d', 'date' => 'Y-m-d',
'datetime' => 'Y-m-d H:i:s', 'datetime' => 'Y-m-d H:i:s',

View file

@ -4,9 +4,6 @@ declare(strict_types=1);
namespace Config; namespace Config;
/**
* @immutable
*/
class DocTypes class DocTypes
{ {
/** /**

View file

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

View file

@ -25,6 +25,23 @@ class Encryption extends BaseConfig
*/ */
public string $key = ''; 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 * Encryption Driver to Use

View file

@ -50,12 +50,14 @@ Events::on('pre_system', static function (): void {
*/ */
if (CI_DEBUG && ! is_cli()) { if (CI_DEBUG && ! is_cli()) {
Events::on('DBQuery', Database::class . '::collect'); Events::on('DBQuery', Database::class . '::collect');
Services::toolbar()->respond(); service('toolbar')
->respond();
// Hot Reload route - for framework use on the hot reloader. // Hot Reload route - for framework use on the hot reloader.
if (ENVIRONMENT === 'development') { if (ENVIRONMENT === 'development') {
Services::routes()->get('__hot-reload', static function (): void { service('routes')->get('__hot-reload', static function (): void {
(new HotReloader())->run(); new HotReloader()
->run();
}); });
} }
} }

View file

@ -62,12 +62,10 @@ class Exceptions extends BaseConfig
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
* LOG DEPRECATIONS INSTEAD OF THROWING? * WHETHER TO THROW AN EXCEPTION ON DEPRECATED ERRORS
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
* By default, CodeIgniter converts deprecations into exceptions. Also, * If set to `true`, DEPRECATED errors are only logged and no exceptions are
* starting in PHP 8.1 will cause a lot of deprecated usage warnings. * thrown. This option also works for user deprecations.
* Use this option to temporarily cease the warnings and instead log those.
* This option also works for user deprecations.
*/ */
public bool $logDeprecations = true; public bool $logDeprecations = true;

View file

@ -12,9 +12,9 @@ use CodeIgniter\Config\BaseConfig;
class Feature extends BaseConfig class Feature extends BaseConfig
{ {
/** /**
* Use improved new auto routing instead of the default legacy version. * Use improved new auto routing instead of the legacy version.
*/ */
public bool $autoRoutesImproved = false; public bool $autoRoutesImproved = true;
/** /**
* Use filter execution order in 4.4 or before. * Use filter execution order in 4.4 or before.
@ -28,4 +28,12 @@ class Feature extends BaseConfig
* If false, `limit(0)` returns no records. (the behavior of 3.1.9 or later in version 3.x.) * If false, `limit(0)` returns no records. (the behavior of 3.1.9 or later in version 3.x.)
*/ */
public bool $limitZeroAsAll = true; public bool $limitZeroAsAll = true;
/**
* Use strict location negotiation.
*
* By default, the locale is selected based on a loose comparison of the language code (ISO 639-1)
* Enabling strict comparison will also consider the region code (ISO 3166-1 alpha-2).
*/
public bool $strictLocaleNegotiation = false;
} }

View file

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

View file

@ -67,13 +67,20 @@ class Filters extends BaseConfig
/** /**
* List of filter aliases that are always applied before and after every request. * List of filter aliases that are always applied before and after every request.
* *
* @var array<string, array<string, array<string, string|array<string>>>>>|array<string, list<string>> * @var array{
* before: array<string, array{except: list<string>|string}>|list<string>,
* after: array<string, array{except: list<string>|string}>|list<string>
* }
*/ */
public array $globals = [ public array $globals = [
'before' => [ 'before' => [
// 'honeypot', // 'honeypot',
'csrf' => [ 'csrf' => [
'except' => ['@[a-zA-Z0-9\_]{1,32}/inbox'], 'except' => [
'@[a-zA-Z0-9\_]{1,32}/inbox',
'api/rest/v1/episodes',
'api/rest/v1/episodes/[0-9]+/publish',
],
], ],
// 'invalidchars', // 'invalidchars',
], ],

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Config; namespace Config;
use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Format\FormatterInterface;
use CodeIgniter\Format\JSONFormatter; use CodeIgniter\Format\JSONFormatter;
use CodeIgniter\Format\XMLFormatter; use CodeIgniter\Format\XMLFormatter;
@ -65,15 +64,12 @@ class Format extends BaseConfig
'text/xml' => 0, 'text/xml' => 0,
]; ];
//--------------------------------------------------------------------
/** /**
* A Factory method to return the appropriate formatter for the given mime type. * --------------------------------------------------------------------------
* Maximum depth for JSON encoding.
* --------------------------------------------------------------------------
* *
* @deprecated This is an alias of `\CodeIgniter\Format\Format::getFormatter`. Use that instead. * This value determines how deep the JSON encoder will traverse nested structures.
*/ */
public function getFormatter(string $mime): FormatterInterface public int $jsonEncodeDepth = 512;
{
return Services::format()->getFormatter($mime);
}
} }

View file

@ -25,7 +25,7 @@ class Generators extends BaseConfig
* *
* YOU HAVE BEEN WARNED! * YOU HAVE BEEN WARNED!
* *
* @var array<string, string> * @var array<string, string|array<string,string>>
*/ */
public array $views = [ public array $views = [
'make:cell' => [ '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. * 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'; public string $libraryPath = '/usr/local/bin/convert';
@ -130,7 +132,7 @@ class Images extends BaseConfig
], ],
]; ];
public string $avatarDefaultPath = 'castopod-avatar.jpg'; public string $avatarDefaultPath = 'assets/images/castopod-avatar.jpg';
public string $avatarDefaultMimeType = 'image/jpg'; public string $avatarDefaultMimeType = 'image/jpg';
@ -139,31 +141,31 @@ class Images extends BaseConfig
*/ */
public array $podcastBannerDefaultPaths = [ public array $podcastBannerDefaultPaths = [
'default' => [ 'default' => [
'path' => 'castopod-banner-pine.jpg', 'path' => 'assets/images/castopod-banner-pine.jpg',
'mimetype' => 'image/jpeg', 'mimetype' => 'image/jpeg',
], ],
'pine' => [ 'pine' => [
'path' => 'castopod-banner-pine.jpg', 'path' => 'assets/images/castopod-banner-pine.jpg',
'mimetype' => 'image/jpeg', 'mimetype' => 'image/jpeg',
], ],
'crimson' => [ 'crimson' => [
'path' => 'castopod-banner-crimson.jpg', 'path' => 'assets/images/castopod-banner-crimson.jpg',
'mimetype' => 'image/jpeg', 'mimetype' => 'image/jpeg',
], ],
'amber' => [ 'amber' => [
'path' => 'castopod-banner-amber.jpg', 'path' => 'assets/images/castopod-banner-amber.jpg',
'mimetype' => 'image/jpeg', 'mimetype' => 'image/jpeg',
], ],
'lake' => [ 'lake' => [
'path' => 'castopod-banner-lake.jpg', 'path' => 'assets/images/castopod-banner-lake.jpg',
'mimetype' => 'image/jpeg', 'mimetype' => 'image/jpeg',
], ],
'jacaranda' => [ 'jacaranda' => [
'path' => 'castopod-banner-jacaranda.jpg', 'path' => 'assets/images/castopod-banner-jacaranda.jpg',
'mimetype' => 'image/jpeg', 'mimetype' => 'image/jpeg',
], ],
'onyx' => [ 'onyx' => [
'path' => 'castopod-banner-onyx.jpg', 'path' => 'assets/images/castopod-banner-onyx.jpg',
'mimetype' => 'image/jpeg', 'mimetype' => 'image/jpeg',
], ],
]; ];

View file

@ -5,13 +5,11 @@ declare(strict_types=1);
namespace Config; namespace Config;
use Kint\Parser\ConstructablePluginInterface; use Kint\Parser\ConstructablePluginInterface;
use Kint\Renderer\AbstractRenderer;
use Kint\Renderer\Rich\TabPluginInterface; use Kint\Renderer\Rich\TabPluginInterface;
use Kint\Renderer\Rich\ValuePluginInterface; use Kint\Renderer\Rich\ValuePluginInterface;
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
* Kint
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
* *
* We use Kint's `RichRenderer` and `CLIRenderer`. This area contains options * We use Kint's `RichRenderer` and `CLIRenderer`. This area contains options
@ -48,8 +46,6 @@ class Kint
public bool $richFolder = false; public bool $richFolder = false;
public int $richSort = AbstractRenderer::SORT_FULL;
/** /**
* @var array<string, class-string<ValuePluginInterface>>|null * @var array<string, class-string<ValuePluginInterface>>|null
*/ */

View file

@ -6,6 +6,7 @@ namespace Config;
use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Log\Handlers\FileHandler; use CodeIgniter\Log\Handlers\FileHandler;
use CodeIgniter\Log\Handlers\HandlerInterface;
class Logger extends BaseConfig class Logger extends BaseConfig
{ {
@ -75,7 +76,7 @@ class Logger extends BaseConfig
* Handlers are executed in the order defined in this array, starting with * Handlers are executed in the order defined in this array, starting with
* the handler on top and continuing down. * the handler on top and continuing down.
* *
* @var array<class-string, array<string, int|list<string>|string>> * @var array<class-string<HandlerInterface>, array<string, int|list<string>|string>>
*/ */
public array $handlers = [ public array $handlers = [
/* /*

View file

@ -46,4 +46,19 @@ class Migrations extends BaseConfig
* - Y_m_d_His_ * - Y_m_d_His_
*/ */
public string $timestampFormat = '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

@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Config; namespace Config;
/** /**
* Mimes
*
* This file contains an array of mime types. It is used by the Upload class to help identify allowed file types. * This file contains an array of mime types. It is used by the Upload class to help identify allowed file types.
* *
* When more than one variation for an extension exist (like jpg, jpeg, etc) the most common one should be first in the * When more than one variation for an extension exist (like jpg, jpeg, etc) the most common one should be first in the
@ -14,8 +12,6 @@ namespace Config;
* *
* When working with mime types, please make sure you have the ´fileinfo´ extension enabled to reliably detect the * When working with mime types, please make sure you have the ´fileinfo´ extension enabled to reliably detect the
* media types. * media types.
*
* @immutable
*/ */
class Mimes class Mimes
{ {
@ -283,7 +279,8 @@ class Mimes
'srt' => ['application/x-subrip', 'text/srt', 'text/plain', 'application/octet-stream'], 'srt' => ['application/x-subrip', 'text/srt', 'text/plain', 'application/octet-stream'],
'vtt' => ['text/vtt', 'text/plain'], 'vtt' => ['text/vtt', 'text/plain'],
'ico' => ['image/x-icon', 'image/x-ico', 'image/vnd.microsoft.icon'], 'ico' => ['image/x-icon', 'image/x-ico', 'image/vnd.microsoft.icon'],
'stl' => ['application/sla', 'application/vnd.ms-pki.stl', 'application/x-navistyle'], 'stl' => ['application/sla', 'application/vnd.ms-pki.stl', 'application/x-navistyle', 'model/stl',
'application/octet-stream', ],
]; ];
/** /**
@ -310,7 +307,7 @@ class Mimes
* @param string|null $proposedExtension - default extension (in case there is more than one with the same mime type) * @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. * @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), '. '); $type = trim(strtolower($type), '. ');

View file

@ -11,8 +11,6 @@ use CodeIgniter\Modules\Modules as BaseModules;
* *
* NOTE: This class is required prior to Autoloader instantiation, * NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig. * and does not extend BaseConfig.
*
* @immutable
*/ */
class Modules extends BaseModules class Modules extends BaseModules
{ {

View file

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

View file

@ -5,16 +5,16 @@ declare(strict_types=1);
namespace Config; namespace Config;
/** /**
* Paths
*
* Holds the paths that are used by the system to locate the main directories, app, system, etc. * Holds the paths that are used by the system to locate the main directories, app, system, etc.
* *
* Modifying these allows you to restructure your application, share a system folder between multiple applications, and * Modifying these allows you to restructure your application, share a system folder between multiple applications, and
* more. * more.
* *
* All paths are relative to the project's root folder. * All paths are relative to the project's root folder.
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*/ */
class Paths class Paths
{ {
/** /**
@ -72,7 +72,7 @@ class Paths
* This variable must contain the name of the directory that * This variable must contain the name of the directory that
* contains the view files used by your application. By * contains the view files used by your application. By
* default this is in `app/Views`. This value * default this is in `app/Views`. This value
* is used when no value is provided to `Services::renderer()`. * is used when no value is provided to `service('renderer')`.
*/ */
public string $viewDirectory = __DIR__ . '/../Views'; public string $viewDirectory = __DIR__ . '/../Views';
} }

View file

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

View file

@ -98,6 +98,15 @@ class Routing extends BaseRouting
*/ */
public bool $autoRoute = false; 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. * For Defined Routes.
* If TRUE, will enable the use of the 'prioritize' option * If TRUE, will enable the use of the 'prioritize' option
@ -138,5 +147,5 @@ class Routing extends BaseRouting
* *
* Default: false * Default: false
*/ */
public bool $translateUriToCamelCase = false; public bool $translateUriToCamelCase = true;
} }

View file

@ -83,23 +83,4 @@ class Security extends BaseConfig
* @see https://codeigniter4.github.io/userguide/libraries/security.html#redirection-on-failure * @see https://codeigniter4.github.io/userguide/libraries/security.html#redirection-on-failure
*/ */
public bool $redirect = (ENVIRONMENT === 'production'); public bool $redirect = (ENVIRONMENT === 'production');
/**
* --------------------------------------------------------------------------
* CSRF SameSite
* --------------------------------------------------------------------------
*
* Setting for CSRF SameSite cookie token.
*
* Allowed values are: None - Lax - Strict - ''.
*
* Defaults to `Lax` as recommended in this link:
*
* @see https://portswigger.net/web-security/csrf/samesite-cookies
*
* @var string
*
* @deprecated `Config\Cookie` $samesite property is used.
*/
public $samesite = 'Lax';
} }

View file

@ -5,12 +5,15 @@ declare(strict_types=1);
namespace Config; namespace Config;
use App\Libraries\Breadcrumb; use App\Libraries\Breadcrumb;
use App\Libraries\HtmlHead;
use App\Libraries\Negotiate; use App\Libraries\Negotiate;
use App\Libraries\Router; use App\Libraries\Router;
use CodeIgniter\Config\BaseService; use CodeIgniter\Config\BaseService;
use CodeIgniter\HTTP\Negotiate as CodeIgniterHTTPNegotiate;
use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\Router\RouteCollectionInterface; use CodeIgniter\Router\RouteCollectionInterface;
use CodeIgniter\Router\Router as CodeIgniterRouter;
/** /**
* Services Configuration file. * Services Configuration file.
@ -31,8 +34,8 @@ class Services extends BaseService
public static function router( public static function router(
?RouteCollectionInterface $routes = null, ?RouteCollectionInterface $routes = null,
?Request $request = null, ?Request $request = null,
bool $getShared = true bool $getShared = true,
): Router { ): CodeIgniterRouter {
if ($getShared) { if ($getShared) {
return static::getSharedInstance('router', $routes, $request); return static::getSharedInstance('router', $routes, $request);
} }
@ -47,8 +50,10 @@ class Services extends BaseService
* The Negotiate class provides the content negotiation features for working the request to determine correct * The Negotiate class provides the content negotiation features for working the request to determine correct
* language, encoding, charset, and more. * language, encoding, charset, and more.
*/ */
public static function negotiator(?RequestInterface $request = null, bool $getShared = true): Negotiate public static function negotiator(
{ ?RequestInterface $request = null,
bool $getShared = true,
): CodeIgniterHTTPNegotiate {
if ($getShared) { if ($getShared) {
return static::getSharedInstance('negotiator', $request); return static::getSharedInstance('negotiator', $request);
} }
@ -66,4 +71,13 @@ class Services extends BaseService
return new Breadcrumb(); 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: * The session storage driver to use:
* - `CodeIgniter\Session\Handlers\ArrayHandler` (for testing)
* - `CodeIgniter\Session\Handlers\FileHandler` * - `CodeIgniter\Session\Handlers\FileHandler`
* - `CodeIgniter\Session\Handlers\DatabaseHandler` * - `CodeIgniter\Session\Handlers\DatabaseHandler`
* - `CodeIgniter\Session\Handlers\MemcachedHandler` * - `CodeIgniter\Session\Handlers\MemcachedHandler`

View file

@ -51,5 +51,9 @@ class Tasks extends BaseConfig
$schedule->command('podcast:import') $schedule->command('podcast:import')
->everyMinute() ->everyMinute()
->named('podcast-import'); ->named('podcast-import');
$schedule->command('episodes:compute-downloads')
->everyHour()
->named('episodes:compute-downloads');
} }
} }

View file

@ -117,4 +117,29 @@ class Toolbar extends BaseConfig
* @var list<string> * @var list<string>
*/ */
public array $watchedExtensions = ['php', 'css', 'js', 'html', 'svg', 'json', 'env']; 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 = [ public array $robots = [
'googlebot' => 'Googlebot', 'googlebot' => 'Googlebot',
'google-pagerenderer' => 'Google Page Renderer',
'google-read-aloud' => 'Google Read Aloud',
'google-safety' => 'Google Safety Bot',
'msnbot' => 'MSNBot', 'msnbot' => 'MSNBot',
'baiduspider' => 'Baiduspider', 'baiduspider' => 'Baiduspider',
'bingbot' => 'Bing', 'bingbot' => 'Bing',
'bingpreview' => 'BingPreview',
'slurp' => 'Inktomi Slurp', 'slurp' => 'Inktomi Slurp',
'yahoo' => 'Yahoo', 'yahoo' => 'Yahoo',
'ask jeeves' => 'Ask Jeeves', 'ask jeeves' => 'Ask Jeeves',
@ -250,5 +254,11 @@ class UserAgents extends BaseConfig
'ia_archiver' => 'Alexa Crawler', 'ia_archiver' => 'Alexa Crawler',
'MJ12bot' => 'Majestic-12', 'MJ12bot' => 'Majestic-12',
'Uptimebot' => 'Uptimebot', '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; namespace Config;
use App\Validation\FileRules as AppFileRules; use App\Validation\FileRules as AppFileRules;
use App\Validation\OtherRules;
use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Validation\StrictRules\CreditCardRules; use CodeIgniter\Validation\StrictRules\CreditCardRules;
use CodeIgniter\Validation\StrictRules\FileRules; use CodeIgniter\Validation\StrictRules\FileRules;
@ -24,6 +25,7 @@ class Validation extends BaseConfig
FileRules::class, FileRules::class,
CreditCardRules::class, CreditCardRules::class,
AppFileRules::class, AppFileRules::class,
OtherRules::class,
]; ];
/** /**

View file

@ -54,4 +54,21 @@ class View extends BaseView
* @var list<class-string<ViewDecoratorInterface>> * @var list<class-string<ViewDecoratorInterface>>
*/ */
public array $decorators = [Decorator::class]; 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';
} }

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

@ -0,0 +1,42 @@
<?php
// app/Config/Vite.php
declare(strict_types=1);
namespace Config;
use CodeIgniterVite\Config\Vite as ViteConfig;
class Vite extends ViteConfig
{
public function __construct()
{
parent::__construct();
$adminGateway = config('Admin')
->gateway;
$installGateway = config('Install')
->gateway;
$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,18 +22,15 @@ class ActorController extends FediverseActorController
*/ */
protected $helpers = ['svg', 'components', 'misc', 'seo']; 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 // @phpstan-ignore-next-line
$this->registerPodcastWebpageHit($this->actor->podcast->id); $this->registerPodcastWebpageHit($this->actor->podcast->id);
}
helper(['form', 'components', 'svg']); helper(['form', 'components', 'svg']);
$data = [
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
'metatags' => get_follow_metatags($this->actor), set_follow_metatags($this->actor);
$data = [
'actor' => $this->actor, 'actor' => $this->actor,
]; ];

View file

@ -5,35 +5,25 @@ declare(strict_types=1);
namespace App\Controllers; namespace App\Controllers;
use CodeIgniter\Controller; use CodeIgniter\Controller;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\ResponseInterface;
use Override;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use ViewThemes\Theme; use ViewThemes\Theme;
/** /**
* BaseController provides a convenient place for loading components and performing functions that are needed by all * 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 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 * An array of helpers to be loaded automatically upon
* class instantiation. These helpers will be available * class instantiation. These helpers will be available
@ -49,14 +39,14 @@ abstract class BaseController extends Controller
*/ */
// protected $session; // protected $session;
/** #[Override]
* Constructor.
*/
public function initController( public function initController(
RequestInterface $request, RequestInterface $request,
ResponseInterface $response, ResponseInterface $response,
LoggerInterface $logger LoggerInterface $logger,
): void { ): 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']; $this->helpers = [...$this->helpers, 'svg', 'components', 'misc', 'seo', 'premium_podcasts'];
// Do Not Edit This Line // Do Not Edit This Line

View file

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

View file

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

View file

@ -16,26 +16,18 @@ use App\Models\EpisodeModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use CodeIgniter\Controller; use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\URI;
use Config\Services;
use Modules\Analytics\Config\Analytics; use Modules\Analytics\Config\Analytics;
use Modules\PremiumPodcasts\Entities\Subscription; use Modules\PremiumPodcasts\Entities\Subscription;
use Modules\PremiumPodcasts\Models\SubscriptionModel; use Modules\PremiumPodcasts\Models\SubscriptionModel;
use Override;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class EpisodeAudioController extends Controller 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 * An array of helpers to be loaded automatically upon class instantiation. These helpers will be available to all
* other controllers that extend Analytics. * other controllers that extend Analytics.
@ -50,13 +42,11 @@ class EpisodeAudioController extends Controller
protected Analytics $analyticsConfig; protected Analytics $analyticsConfig;
/** #[Override]
* Constructor.
*/
public function initController( public function initController(
RequestInterface $request, RequestInterface $request,
ResponseInterface $response, ResponseInterface $response,
LoggerInterface $logger LoggerInterface $logger,
): void { ): void {
// Do Not Edit This Line // Do Not Edit This Line
parent::initController($request, $response, $logger); parent::initController($request, $response, $logger);
@ -75,7 +65,7 @@ class EpisodeAudioController extends Controller
} }
if ( if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast ! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -83,7 +73,7 @@ class EpisodeAudioController extends Controller
$this->podcast = $podcast; $this->podcast = $podcast;
if ( if (
! ($episode = (new EpisodeModel())->getEpisodeBySlug($params[0], $params[1])) instanceof Episode ! ($episode = new EpisodeModel()->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -103,7 +93,7 @@ class EpisodeAudioController extends Controller
// check if podcast is already unlocked before any token validation // check if podcast is already unlocked before any token validation
if ($this->episode->is_premium && ! ($subscription = service('premium_podcasts')->subscription( if ($this->episode->is_premium && ! ($subscription = service('premium_podcasts')->subscription(
$this->episode->podcast->handle $this->episode->podcast->handle,
)) instanceof Subscription) { )) instanceof Subscription) {
// look for token as GET parameter // look for token as GET parameter
if (($token = $this->request->getGet('token')) === null) { if (($token = $this->request->getGet('token')) === null) {
@ -118,9 +108,9 @@ class EpisodeAudioController extends Controller
} }
// check if there's a valid subscription for the provided token // 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, $this->episode->podcast->handle,
$token $token,
)) instanceof Subscription) { )) instanceof Subscription) {
return $this->response->setStatusCode(401, 'Invalid token!') return $this->response->setStatusCode(401, 'Invalid token!')
->setJSON([ ->setJSON([
@ -133,7 +123,7 @@ class EpisodeAudioController extends Controller
} }
} }
$session = Services::session(); $session = service('session');
$serviceName = ''; $serviceName = '';
if ($this->request->getGet('_from')) { if ($this->request->getGet('_from')) {
@ -164,7 +154,7 @@ class EpisodeAudioController extends Controller
$audioDuration, $audioDuration,
$this->episode->published_at->getTimestamp(), $this->episode->published_at->getTimestamp(),
$serviceName, $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)); $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 App\Models\PodcastModel;
use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface;
use Modules\Analytics\AnalyticsTrait; use Modules\Analytics\AnalyticsTrait;
use Modules\Fediverse\Entities\Actor; use Modules\Fediverse\Entities\Actor;
use Modules\Fediverse\Objects\OrderedCollectionObject; use Modules\Fediverse\Objects\OrderedCollectionObject;
@ -44,7 +44,7 @@ class EpisodeCommentController extends BaseController
} }
if ( if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast ! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -53,7 +53,7 @@ class EpisodeCommentController extends BaseController
$this->actor = $podcast->actor; $this->actor = $podcast->actor;
if ( if (
! ($episode = (new EpisodeModel())->getEpisodeBySlug($params[0], $params[1])) instanceof Episode ! ($episode = new EpisodeModel()->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -61,7 +61,7 @@ class EpisodeCommentController extends BaseController
$this->episode = $episode; $this->episode = $episode;
if ( if (
! ($comment = (new EpisodeCommentModel())->getCommentById($params[2])) instanceof EpisodeComment ! ($comment = new EpisodeCommentModel()->getCommentById($params[2])) instanceof EpisodeComment
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -77,10 +77,7 @@ class EpisodeCommentController extends BaseController
public function view(): string public function view(): string
{ {
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id); $this->registerPodcastWebpageHit($this->podcast->id);
}
$cacheName = implode( $cacheName = implode(
'_', '_',
@ -96,8 +93,8 @@ class EpisodeCommentController extends BaseController
); );
if (! ($cachedView = cache($cacheName))) { if (! ($cachedView = cache($cacheName))) {
set_episode_comment_metatags($this->comment);
$data = [ $data = [
'metatags' => get_episode_comment_metatags($this->comment),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'actor' => $this->actor, 'actor' => $this->actor,
'episode' => $this->episode, 'episode' => $this->episode,
@ -119,7 +116,7 @@ class EpisodeCommentController extends BaseController
return $cachedView; return $cachedView;
} }
public function commentObject(): Response public function commentObject(): ResponseInterface
{ {
$commentObject = new CommentObject($this->comment); $commentObject = new CommentObject($this->comment);
@ -128,7 +125,7 @@ class EpisodeCommentController extends BaseController
->setBody($commentObject->toJSON()); ->setBody($commentObject->toJSON());
} }
public function replies(): Response public function replies(): ResponseInterface
{ {
/** /**
* get comment replies * get comment replies
@ -148,12 +145,10 @@ class EpisodeCommentController extends BaseController
$pager = $commentReplies->pager; $pager = $commentReplies->pager;
$orderedItems = []; $orderedItems = [];
if ($paginatedReplies !== null) {
foreach ($paginatedReplies as $reply) { foreach ($paginatedReplies as $reply) {
$replyObject = new CommentObject($reply); $replyObject = new CommentObject($reply);
$orderedItems[] = $replyObject; $orderedItems[] = $replyObject;
} }
}
$collection = new OrderedCollectionPage($pager, $orderedItems); $collection = new OrderedCollectionPage($pager, $orderedItems);
} }
@ -163,7 +158,7 @@ class EpisodeCommentController extends BaseController
->setBody($collection->toJSON()); ->setBody($collection->toJSON());
} }
public function attemptLike(): RedirectResponse public function likeAction(): RedirectResponse
{ {
if (! ($interactAsActor = interact_as_actor()) instanceof Actor) { if (! ($interactAsActor = interact_as_actor()) instanceof Actor) {
return redirect()->back(); return redirect()->back();
@ -175,7 +170,7 @@ class EpisodeCommentController extends BaseController
return redirect()->back(); return redirect()->back();
} }
public function attemptReply(): RedirectResponse public function replyAction(): RedirectResponse
{ {
if (! ($interactAsActor = interact_as_actor()) instanceof Actor) { if (! ($interactAsActor = interact_as_actor()) instanceof Actor) {
return redirect()->back(); return redirect()->back();

View file

@ -18,10 +18,8 @@ use App\Models\EpisodeModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\ResponseInterface;
use Config\Embed; use Config\Embed;
use Config\Services;
use Modules\Analytics\AnalyticsTrait; use Modules\Analytics\AnalyticsTrait;
use Modules\Fediverse\Objects\OrderedCollectionObject; use Modules\Fediverse\Objects\OrderedCollectionObject;
use Modules\Fediverse\Objects\OrderedCollectionPage; use Modules\Fediverse\Objects\OrderedCollectionPage;
@ -43,7 +41,7 @@ class EpisodeController extends BaseController
} }
if ( if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast ! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -51,7 +49,7 @@ class EpisodeController extends BaseController
$this->podcast = $podcast; $this->podcast = $podcast;
if ( if (
! ($episode = (new EpisodeModel())->getEpisodeBySlug($params[0], $params[1])) instanceof Episode ! ($episode = new EpisodeModel()->getEpisodeBySlug($params[0], $params[1])) instanceof Episode
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -66,10 +64,7 @@ class EpisodeController extends BaseController
public function index(): string 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( $cacheName = implode(
'_', '_',
@ -86,15 +81,14 @@ class EpisodeController extends BaseController
); );
if (! ($cachedView = cache($cacheName))) { if (! ($cachedView = cache($cacheName))) {
set_episode_metatags($this->episode);
$data = [ $data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'episode' => $this->episode, 'episode' => $this->episode,
]; ];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$this->podcast->id, ->getSecondsToNextUnpublishedEpisode($this->podcast->id);
);
if (auth()->loggedIn()) { if (auth()->loggedIn()) {
helper('form'); helper('form');
@ -114,10 +108,7 @@ class EpisodeController extends BaseController
public function activity(): string 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( $cacheName = implode(
'_', '_',
@ -135,15 +126,14 @@ class EpisodeController extends BaseController
); );
if (! ($cachedView = cache($cacheName))) { if (! ($cachedView = cache($cacheName))) {
set_episode_metatags($this->episode);
$data = [ $data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'episode' => $this->episode, 'episode' => $this->episode,
]; ];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$this->podcast->id, ->getSecondsToNextUnpublishedEpisode($this->podcast->id);
);
if (auth()->loggedIn()) { if (auth()->loggedIn()) {
helper('form'); helper('form');
@ -163,10 +153,7 @@ class EpisodeController extends BaseController
public function chapters(): string 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( $cacheName = implode(
'_', '_',
@ -184,13 +171,13 @@ class EpisodeController extends BaseController
); );
if (! ($cachedView = cache($cacheName))) { if (! ($cachedView = cache($cacheName))) {
// get chapters from json file set_episode_metatags($this->episode);
$data = [ $data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'episode' => $this->episode, 'episode' => $this->episode,
]; ];
// get chapters from json file
if (isset($this->episode->chapters->file_key)) { if (isset($this->episode->chapters->file_key)) {
/** @var FileManagerInterface $fileManager */ /** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager'); $fileManager = service('file_manager');
@ -200,9 +187,8 @@ class EpisodeController extends BaseController
$data['chapters'] = $chapters; $data['chapters'] = $chapters;
} }
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$this->podcast->id, ->getSecondsToNextUnpublishedEpisode($this->podcast->id);
);
if (auth()->loggedIn()) { if (auth()->loggedIn()) {
helper('form'); helper('form');
@ -222,10 +208,7 @@ class EpisodeController extends BaseController
public function transcript(): string 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( $cacheName = implode(
'_', '_',
@ -243,13 +226,13 @@ class EpisodeController extends BaseController
); );
if (! ($cachedView = cache($cacheName))) { if (! ($cachedView = cache($cacheName))) {
// get transcript from json file set_episode_metatags($this->episode);
$data = [ $data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'episode' => $this->episode, 'episode' => $this->episode,
]; ];
// get transcript from json file
if ($this->episode->transcript !== null) { if ($this->episode->transcript !== null) {
$data['transcript'] = $this->episode->transcript; $data['transcript'] = $this->episode->transcript;
@ -257,16 +240,15 @@ class EpisodeController extends BaseController
/** @var FileManagerInterface $fileManager */ /** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager'); $fileManager = service('file_manager');
$transcriptJsonString = (string) $fileManager->getFileContents( $transcriptJsonString = (string) $fileManager->getFileContents(
$this->episode->transcript->json_key $this->episode->transcript->json_key,
); );
$data['captions'] = json_decode($transcriptJsonString, true); $data['captions'] = json_decode($transcriptJsonString, true);
} }
} }
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$this->podcast->id, ->getSecondsToNextUnpublishedEpisode($this->podcast->id);
);
if (auth()->loggedIn()) { if (auth()->loggedIn()) {
helper('form'); helper('form');
@ -288,12 +270,9 @@ class EpisodeController extends BaseController
{ {
header('Content-Security-Policy: frame-ancestors http://*:* https://*:*'); 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 = Services::session(); $session = service('session');
if (service('superglobals')->server('HTTP_REFERER') !== null) { if (service('superglobals')->server('HTTP_REFERER') !== null) {
$session->set('embed_domain', parse_url(service('superglobals')->server('HTTP_REFERER'), PHP_URL_HOST)); $session->set('embed_domain', parse_url(service('superglobals')->server('HTTP_REFERER'), PHP_URL_HOST));
@ -323,9 +302,8 @@ class EpisodeController extends BaseController
'themeData' => $themeData, 'themeData' => $themeData,
]; ];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$this->podcast->id, ->getSecondsToNextUnpublishedEpisode($this->podcast->id);
);
// The page cache is set to a decade so it is deleted manually upon podcast update // The page cache is set to a decade so it is deleted manually upon podcast update
return view('embed', $data, [ return view('embed', $data, [
@ -382,7 +360,7 @@ class EpisodeController extends BaseController
'<iframe src="' . '<iframe src="' .
$this->episode->embed_url . $this->episode->embed_url .
'" width="100%" height="' . config( '" width="100%" height="' . config(
Embed::class Embed::class,
)->height . '" frameborder="0" scrolling="no"></iframe>', )->height . '" frameborder="0" scrolling="no"></iframe>',
), ),
); );
@ -393,7 +371,7 @@ class EpisodeController extends BaseController
return $this->response->setXML($oembed); return $this->response->setXML($oembed);
} }
public function episodeObject(): Response public function episodeObject(): ResponseInterface
{ {
$podcastObject = new PodcastEpisode($this->episode); $podcastObject = new PodcastEpisode($this->episode);
@ -402,7 +380,7 @@ class EpisodeController extends BaseController
->setBody($podcastObject->toJSON()); ->setBody($podcastObject->toJSON());
} }
public function comments(): Response public function comments(): ResponseInterface
{ {
/** /**
* get comments: aggregated replies from posts referring to the episode * get comments: aggregated replies from posts referring to the episode
@ -425,10 +403,8 @@ class EpisodeController extends BaseController
$pager = $episodeComments->pager; $pager = $episodeComments->pager;
$orderedItems = []; $orderedItems = [];
if ($paginatedComments !== null) {
foreach ($paginatedComments as $comment) { foreach ($paginatedComments as $comment) {
$orderedItems[] = (new NoteObject($comment))->toArray(); $orderedItems[] = new NoteObject($comment)->toArray();
}
} }
// @phpstan-ignore-next-line // @phpstan-ignore-next-line

View file

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

View file

@ -33,14 +33,26 @@ class FeedController extends Controller
public function index(string $podcastHandle): ResponseInterface public function index(string $podcastHandle): ResponseInterface
{ {
helper(['rss', 'premium_podcasts', 'misc']); $podcast = new PodcastModel()
->where('handle', $podcastHandle)
$podcast = (new PodcastModel())->where('handle', $podcastHandle)
->first(); ->first();
if (! $podcast instanceof Podcast) { if (! $podcast instanceof Podcast) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
// 301 redirect to new feed?
$redirectToNewFeed = service('settings')
->get('Podcast.redirect_to_new_feed', 'podcast:' . $podcast->id);
if ($redirectToNewFeed && $podcast->new_feed_url !== null && filter_var(
$podcast->new_feed_url,
FILTER_VALIDATE_URL,
) && $podcast->new_feed_url !== current_url()) {
return redirect()->to($podcast->new_feed_url, 301);
}
helper(['rss', 'premium_podcasts', 'misc']);
$service = null; $service = null;
try { try {
$service = UserAgentsRSS::find(service('superglobals')->server('HTTP_USER_AGENT')); $service = UserAgentsRSS::find(service('superglobals')->server('HTTP_USER_AGENT'));
@ -57,7 +69,8 @@ class FeedController extends Controller
$subscription = null; $subscription = null;
$token = $this->request->getGet('token'); $token = $this->request->getGet('token');
if ($token) { if ($token) {
$subscription = (new SubscriptionModel())->validateSubscription($podcastHandle, $token); $subscription = new SubscriptionModel()
->validateSubscription($podcastHandle, $token);
} }
$cacheName = implode( $cacheName = implode(
@ -66,7 +79,7 @@ class FeedController extends Controller
"podcast#{$podcast->id}", "podcast#{$podcast->id}",
'feed', 'feed',
$service ? $serviceSlug : null, $service ? $serviceSlug : null,
$subscription instanceof Subscription ? 'unlocked' : null, $subscription instanceof Subscription ? "subscription#{$subscription->id}" : null,
]), ]),
); );
@ -74,9 +87,8 @@ class FeedController extends Controller
$found = get_rss_feed($podcast, $serviceSlug, $subscription, $token); $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 // 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( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$podcast->id, ->getSecondsToNextUnpublishedEpisode($podcast->id);
);
cache() cache()
->save($cacheName, $found, $secondsToNextUnpublishedEpisode ?: DECADE); ->save($cacheName, $found, $secondsToNextUnpublishedEpisode ?: DECADE);

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ use App\Models\EpisodeModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use App\Models\PostModel; use App\Models\PostModel;
use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface;
use Modules\Analytics\AnalyticsTrait; use Modules\Analytics\AnalyticsTrait;
use Modules\Fediverse\Objects\OrderedCollectionObject; use Modules\Fediverse\Objects\OrderedCollectionObject;
use Modules\Fediverse\Objects\OrderedCollectionPage; use Modules\Fediverse\Objects\OrderedCollectionPage;
@ -35,7 +35,7 @@ class PodcastController extends BaseController
} }
if ( if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast ! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -47,7 +47,7 @@ class PodcastController extends BaseController
return $this->{$method}(...$params); return $this->{$method}(...$params);
} }
public function podcastActor(): Response public function podcastActor(): ResponseInterface
{ {
$podcastActor = new PodcastActor($this->podcast); $podcastActor = new PodcastActor($this->podcast);
@ -58,10 +58,7 @@ class PodcastController extends BaseController
public function activity(): string public function activity(): string
{ {
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id); $this->registerPodcastWebpageHit($this->podcast->id);
}
$cacheName = implode( $cacheName = implode(
'_', '_',
@ -78,10 +75,11 @@ class PodcastController extends BaseController
); );
if (! ($cachedView = cache($cacheName))) { if (! ($cachedView = cache($cacheName))) {
set_podcast_metatags($this->podcast, 'activity');
$data = [ $data = [
'metatags' => get_podcast_metatags($this->podcast, 'activity'),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'posts' => (new PostModel())->getActorPublishedPosts($this->podcast->actor_id), 'posts' => new PostModel()
->getActorPublishedPosts($this->podcast->actor_id),
]; ];
// if user is logged in then send to the authenticated activity view // 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); return view('podcast/activity', $data);
} }
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$this->podcast->id, ->getSecondsToNextUnpublishedEpisode($this->podcast->id);
);
return view('podcast/activity', $data, [ return view('podcast/activity', $data, [
'cache' => $secondsToNextUnpublishedEpisode ?: DECADE, 'cache' => $secondsToNextUnpublishedEpisode ?: DECADE,
@ -106,10 +103,7 @@ class PodcastController extends BaseController
public function about(): string public function about(): string
{ {
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id); $this->registerPodcastWebpageHit($this->podcast->id);
}
$cacheName = implode( $cacheName = implode(
'_', '_',
@ -126,10 +120,11 @@ class PodcastController extends BaseController
); );
if (! ($cachedView = cache($cacheName))) { 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 = [ $data = [
'metatags' => get_podcast_metatags($this->podcast, 'about'),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'stats' => $stats, 'stats' => $stats,
]; ];
@ -141,9 +136,8 @@ class PodcastController extends BaseController
return view('podcast/about', $data); return view('podcast/about', $data);
} }
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$this->podcast->id, ->getSecondsToNextUnpublishedEpisode($this->podcast->id);
);
return view('podcast/about', $data, [ return view('podcast/about', $data, [
'cache' => $secondsToNextUnpublishedEpisode ?: DECADE, 'cache' => $secondsToNextUnpublishedEpisode ?: DECADE,
@ -156,16 +150,14 @@ class PodcastController extends BaseController
public function episodes(): string 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'); $yearQuery = $this->request->getGet('year');
$seasonQuery = $this->request->getGet('season'); $seasonQuery = $this->request->getGet('season');
if (! $yearQuery && ! $seasonQuery) { if (! $yearQuery && ! $seasonQuery) {
$defaultQuery = (new PodcastModel())->getDefaultQuery($this->podcast->id); $defaultQuery = new PodcastModel()
->getDefaultQuery($this->podcast->id);
if ($defaultQuery) { if ($defaultQuery) {
if ($defaultQuery['type'] === 'season') { if ($defaultQuery['type'] === 'season') {
$seasonQuery = $defaultQuery['data']['season_number']; $seasonQuery = $defaultQuery['data']['season_number'];
@ -245,26 +237,21 @@ class PodcastController extends BaseController
]; ];
} }
set_podcast_metatags($this->podcast, 'episodes');
$data = [ $data = [
'metatags' => get_podcast_metatags($this->podcast, 'episodes'),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation, 'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery, 'activeQuery' => $activeQuery,
'episodes' => (new EpisodeModel())->getPodcastEpisodes( 'episodes' => new EpisodeModel()
$this->podcast->id, ->getPodcastEpisodes($this->podcast->id, $this->podcast->type, $yearQuery, $seasonQuery),
$this->podcast->type,
$yearQuery,
$seasonQuery,
),
]; ];
if (auth()->loggedIn()) { if (auth()->loggedIn()) {
return view('podcast/episodes', $data); return view('podcast/episodes', $data);
} }
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $secondsToNextUnpublishedEpisode = new EpisodeModel()
$this->podcast->id, ->getSecondsToNextUnpublishedEpisode($this->podcast->id);
);
return view('podcast/episodes', $data, [ return view('podcast/episodes', $data, [
'cache' => $secondsToNextUnpublishedEpisode ?: DECADE, 'cache' => $secondsToNextUnpublishedEpisode ?: DECADE,
'cache_name' => $cacheName, 'cache_name' => $cacheName,
@ -274,7 +261,7 @@ class PodcastController extends BaseController
return $cachedView; return $cachedView;
} }
public function episodeCollection(): Response public function episodeCollection(): ResponseInterface
{ {
if ($this->podcast->type === 'serial') { if ($this->podcast->type === 'serial') {
// podcast is serial // podcast is serial
@ -298,10 +285,8 @@ class PodcastController extends BaseController
$pager = $episodes->pager; $pager = $episodes->pager;
$orderedItems = []; $orderedItems = [];
if ($paginatedEpisodes !== null) {
foreach ($paginatedEpisodes as $episode) { foreach ($paginatedEpisodes as $episode) {
$orderedItems[] = (new PodcastEpisode($episode))->toArray(); $orderedItems[] = new PodcastEpisode($episode)->toArray();
}
} }
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
@ -315,8 +300,8 @@ class PodcastController extends BaseController
public function links(): string public function links(): string
{ {
set_podcast_metatags($this->podcast, 'links');
return view('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 CodeIgniter\I18n\Time;
use Modules\Analytics\AnalyticsTrait; use Modules\Analytics\AnalyticsTrait;
use Modules\Fediverse\Controllers\PostController as FediversePostController; use Modules\Fediverse\Controllers\PostController as FediversePostController;
use Override;
class PostController extends FediversePostController class PostController extends FediversePostController
{ {
@ -41,10 +42,12 @@ class PostController extends FediversePostController
*/ */
protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo', 'premium_podcasts']; protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo', 'premium_podcasts'];
#[Override]
public function _remap(string $method, string ...$params): mixed public function _remap(string $method, string ...$params): mixed
{ {
if ( if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast ! ($podcast = new PodcastModel()->getPodcastByHandle($params[0])) instanceof Podcast
) { ) {
throw PageNotFoundException::forPageNotFound(); throw PageNotFoundException::forPageNotFound();
} }
@ -52,29 +55,34 @@ class PostController extends FediversePostController
$this->podcast = $podcast; $this->podcast = $podcast;
$this->actor = $this->podcast->actor; $this->actor = $this->podcast->actor;
if (count($params) <= 1) {
unset($params[0]);
return $this->{$method}(...$params);
}
if ( if (
count($params) > 1 && ! ($post = new PostModel()->getPostById($params[1])) instanceof CastopodPost
($post = (new PostModel())->getPostById($params[1])) instanceof CastopodPost
) { ) {
throw PageNotFoundException::forPageNotFound();
}
$this->post = $post; $this->post = $post;
// show 404 if post is private
if ($this->post->is_private && ! can_user_interact()) {
throw PageNotFoundException::forPageNotFound();
}
unset($params[0]); unset($params[0]);
unset($params[1]); unset($params[1]);
}
return $this->{$method}(...$params); return $this->{$method}(...$params);
} }
public function view(): string public function view(): string
{ {
// Prevent analytics hit when authenticated
if (! auth()->loggedIn()) {
$this->registerPodcastWebpageHit($this->podcast->id); $this->registerPodcastWebpageHit($this->podcast->id);
}
if (! $this->post instanceof CastopodPost) {
throw PageNotFoundException::forPageNotFound();
}
$cacheName = implode( $cacheName = implode(
'_', '_',
@ -89,8 +97,8 @@ class PostController extends FediversePostController
); );
if (! ($cachedView = cache($cacheName))) { if (! ($cachedView = cache($cacheName))) {
set_post_metatags($this->post);
$data = [ $data = [
'metatags' => get_post_metatags($this->post),
'post' => $this->post, 'post' => $this->post,
'podcast' => $this->podcast, 'podcast' => $this->podcast,
]; ];
@ -110,7 +118,8 @@ class PostController extends FediversePostController
return $cachedView; return $cachedView;
} }
public function attemptCreate(): RedirectResponse #[Override]
public function createAction(): RedirectResponse
{ {
$rules = [ $rules = [
'message' => 'required|max_length[500]', 'message' => 'required|max_length[500]',
@ -139,7 +148,7 @@ class PostController extends FediversePostController
if ( if (
$episodeUri && $episodeUri &&
($params = extract_params_from_episode_uri(new URI($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; $newPost->episode_id = $episode->id;
} }
@ -161,7 +170,8 @@ class PostController extends FediversePostController
return redirect()->back(); return redirect()->back();
} }
public function attemptReply(): RedirectResponse #[Override]
public function replyAction(): RedirectResponse
{ {
$rules = [ $rules = [
'message' => 'required|max_length[500]', 'message' => 'required|max_length[500]',
@ -180,6 +190,7 @@ class PostController extends FediversePostController
'actor_id' => interact_as_actor_id(), 'actor_id' => interact_as_actor_id(),
'in_reply_to_id' => $this->post->id, 'in_reply_to_id' => $this->post->id,
'message' => $validData['message'], 'message' => $validData['message'],
'is_private' => $this->post->is_private,
'published_at' => Time::now(), 'published_at' => Time::now(),
'created_by' => user_id(), 'created_by' => user_id(),
]); ]);
@ -200,21 +211,24 @@ class PostController extends FediversePostController
return redirect()->back(); return redirect()->back();
} }
public function attemptFavourite(): RedirectResponse #[Override]
public function favouriteAction(): RedirectResponse
{ {
model('FavouriteModel')->toggleFavourite(interact_as_actor(), $this->post); model('FavouriteModel')->toggleFavourite(interact_as_actor(), $this->post);
return redirect()->back(); 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(); return redirect()->back();
} }
public function attemptAction(): RedirectResponse public function action(): RedirectResponse
{ {
$rules = [ $rules = [
'action' => 'required|in_list[favourite,reblog,reply]', 'action' => 'required|in_list[favourite,reblog,reply]',
@ -231,9 +245,9 @@ class PostController extends FediversePostController
$action = $validData['action']; $action = $validData['action'];
return match ($action) { return match ($action) {
'favourite' => $this->attemptFavourite(), 'favourite' => $this->favouriteAction(),
'reblog' => $this->attemptReblog(), 'reblog' => $this->reblogAction(),
'reply' => $this->attemptReply(), 'reply' => $this->replyAction(),
default => redirect() default => redirect()
->back() ->back()
->withInput() ->withInput()
@ -241,15 +255,12 @@ 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 = [ $data = [
'metatags' => get_remote_actions_metatags($this->post, $action),
'podcast' => $this->podcast, 'podcast' => $this->podcast,
'actor' => $this->actor, 'actor' => $this->actor,
'post' => $this->post, 'post' => $this->post,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddEpisodeDownloadsCount extends Migration
{
public function up(): void
{
$fields = [
'downloads_count' => [
'type' => 'INT',
'unsigned' => true,
'default' => 0,
'after' => 'is_published_on_hubs',
],
];
$this->forge->addColumn('episodes', $fields);
}
public function down(): void
{
$this->forge->dropColumn('episodes', 'downloads_count');
}
}

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\BaseConnection;
use CodeIgniter\Database\Migration; use CodeIgniter\Database\Migration;
use Override;
class BaseMigration extends Migration class BaseMigration extends Migration
{ {
@ -24,10 +25,12 @@ class BaseMigration extends Migration
*/ */
protected $db; protected $db;
#[Override]
public function up(): void public function up(): void
{ {
} }
#[Override]
public function down(): void public function down(): void
{ {
} }

View file

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

View file

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

View file

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

View file

@ -15,12 +15,14 @@ namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder; use CodeIgniter\Database\Seeder;
use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Entities\User;
use Modules\Auth\Models\UserModel; use Modules\Auth\Models\UserModel;
use Override;
class DevSuperadminSeeder extends Seeder class DevSuperadminSeeder extends Seeder
{ {
#[Override]
public function run(): void 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; return;
} }

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ namespace App\Entities;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use Modules\Fediverse\Entities\Actor as FediverseActor; use Modules\Fediverse\Entities\Actor as FediverseActor;
use RuntimeException; use Override;
/** /**
* @property Podcast|null $podcast * @property Podcast|null $podcast
@ -31,17 +31,15 @@ class Actor extends FediverseActor
public function getPodcast(): ?Podcast public function getPodcast(): ?Podcast
{ {
if ($this->id === null) {
throw new RuntimeException('Podcast id must be set before getting associated podcast.');
}
if (! $this->podcast instanceof Podcast) { if (! $this->podcast instanceof Podcast) {
$this->podcast = (new PodcastModel())->getPodcastByActorId($this->id); $this->podcast = new PodcastModel()
->getPodcastByActorId($this->id);
} }
return $this->podcast; return $this->podcast;
} }
#[Override]
public function getAvatarImageUrl(): string public function getAvatarImageUrl(): string
{ {
if ($this->podcast instanceof Podcast) { if ($this->podcast instanceof Podcast) {
@ -51,6 +49,7 @@ class Actor extends FediverseActor
return parent::getAvatarImageUrl(); return parent::getAvatarImageUrl();
} }
#[Override]
public function getAvatarImageMimetype(): string public function getAvatarImageMimetype(): string
{ {
if ($this->podcast instanceof Podcast) { if ($this->podcast instanceof Podcast) {

View file

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

View file

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

View file

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

View file

@ -55,12 +55,9 @@ class Credit extends Entity
public function getPerson(): ?Person public function getPerson(): ?Person
{ {
if ($this->person_id === null) {
throw new RuntimeException('Credit must have person_id before getting person.');
}
if (! $this->person instanceof 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; return $this->person;
@ -68,12 +65,9 @@ class Credit extends Entity
public function getPodcast(): ?Podcast public function getPodcast(): ?Podcast
{ {
if ($this->podcast_id === null) {
throw new RuntimeException('Credit must have podcast_id before getting podcast.');
}
if (! $this->podcast instanceof 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; return $this->podcast;
@ -86,7 +80,8 @@ class Credit extends Entity
} }
if (! $this->episode instanceof Episode) { 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; return $this->episode;
@ -94,7 +89,7 @@ class Credit extends Entity
public function getGroupLabel(): string public function getGroupLabel(): string
{ {
if ($this->person_group === null) { if ($this->person_group === '') {
return ''; return '';
} }

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace App\Entities; namespace App\Entities;
use App\Entities\Clip\Soundbite; use App\Entities\Clip\Soundbite;
use App\Libraries\SimpleRSSElement;
use App\Models\ClipModel; use App\Models\ClipModel;
use App\Models\EpisodeCommentModel; use App\Models\EpisodeCommentModel;
use App\Models\EpisodeModel; use App\Models\EpisodeModel;
@ -29,14 +28,12 @@ use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension; use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension; use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\MarkdownConverter; use League\CommonMark\MarkdownConverter;
use Modules\Analytics\OP3;
use Modules\Media\Entities\Audio; use Modules\Media\Entities\Audio;
use Modules\Media\Entities\Chapters; use Modules\Media\Entities\Chapters;
use Modules\Media\Entities\Image; use Modules\Media\Entities\Image;
use Modules\Media\Entities\Transcript; use Modules\Media\Entities\Transcript;
use Modules\Media\Models\MediaModel; use Modules\Media\Models\MediaModel;
use RuntimeException; use Override;
use SimpleXMLElement;
/** /**
* @property int $id * @property int $id
@ -65,20 +62,18 @@ use SimpleXMLElement;
* @property Chapters|null $chapters * @property Chapters|null $chapters
* @property string|null $chapters_remote_url * @property string|null $chapters_remote_url
* @property string|null $parental_advisory * @property string|null $parental_advisory
* @property int $number * @property ?int $number
* @property int $season_number * @property ?int $season_number
* @property string $type * @property string $type
* @property bool $is_blocked * @property bool $is_blocked
* @property Location|null $location * @property Location|null $location
* @property string|null $location_name * @property string|null $location_name
* @property string|null $location_geo * @property string|null $location_geo
* @property string|null $location_osm * @property string|null $location_osm
* @property array|null $custom_rss
* @property string $custom_rss_string
* @property bool $is_published_on_hubs * @property bool $is_published_on_hubs
* @property int $downloads_count
* @property int $posts_count * @property int $posts_count
* @property int $comments_count * @property int $comments_count
* @property int $downloads
* @property EpisodeComment[]|null $comments * @property EpisodeComment[]|null $comments
* @property bool $is_premium * @property bool $is_premium
* @property int $created_by * @property int $created_by
@ -94,19 +89,19 @@ use SimpleXMLElement;
*/ */
class Episode extends Entity 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 ?Audio $audio = null;
protected string $audio_url; protected string $embed_url = '';
protected string $audio_web_url;
protected string $audio_opengraph_url;
protected string $embed_url;
protected ?Image $cover = null; protected ?Image $cover = null;
@ -116,8 +111,6 @@ class Episode extends Entity
protected ?Chapters $chapters = null; protected ?Chapters $chapters = null;
protected int $downloads = 0;
/** /**
* @var Person[]|null * @var Person[]|null
*/ */
@ -140,8 +133,6 @@ class Episode extends Entity
protected ?Location $location = null; protected ?Location $location = null;
protected string $custom_rss_string;
protected ?string $publication_status = null; protected ?string $publication_status = null;
/** /**
@ -176,8 +167,8 @@ class Episode extends Entity
'location_name' => '?string', 'location_name' => '?string',
'location_geo' => '?string', 'location_geo' => '?string',
'location_osm' => '?string', 'location_osm' => '?string',
'custom_rss' => '?json-array',
'is_published_on_hubs' => 'boolean', 'is_published_on_hubs' => 'boolean',
'downloads_count' => 'integer',
'posts_count' => 'integer', 'posts_count' => 'integer',
'comments_count' => 'integer', 'comments_count' => 'integer',
'is_premium' => 'boolean', 'is_premium' => 'boolean',
@ -185,7 +176,32 @@ class Episode extends Entity
'updated_by' => 'integer', '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())) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this; return $this;
@ -196,18 +212,19 @@ class Episode extends Entity
->setFile($file); ->setFile($file);
$this->getCover() $this->getCover()
->updated_by = $this->attributes['updated_by']; ->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getCover()); new MediaModel('image')
->updateMedia($this->getCover());
} else { } else {
$cover = new Image([ $cover = new Image([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '.' . $file->getExtension(), 'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '.' . $file->getExtension(),
'sizes' => config('Images') 'sizes' => config('Images')
->podcastCoverSizes, ->podcastCoverSizes,
'uploaded_by' => $this->attributes['updated_by'], 'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'], 'updated_by' => $this->attributes['updated_by'],
]); ]);
$cover->setFile($file); $cover->setFile($file);
$this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover); $this->attributes['cover_id'] = new MediaModel('image')->saveMedia($cover);
} }
return $this; return $this;
@ -226,12 +243,13 @@ class Episode extends Entity
return $this->cover; return $this->cover;
} }
$this->cover = (new MediaModel('image'))->getMediaById($this->cover_id); $this->cover = new MediaModel('image')
->getMediaById($this->cover_id);
return $this->cover; 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())) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this; return $this;
@ -242,7 +260,8 @@ class Episode extends Entity
->setFile($file); ->setFile($file);
$this->getAudio() $this->getAudio()
->updated_by = $this->attributes['updated_by']; ->updated_by = $this->attributes['updated_by'];
(new MediaModel('audio'))->updateMedia($this->getAudio()); new MediaModel('audio')
->updateMedia($this->getAudio());
} else { } else {
$audio = new Audio([ $audio = new Audio([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $file->getRandomName(), 'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $file->getRandomName(),
@ -253,7 +272,7 @@ class Episode extends Entity
]); ]);
$audio->setFile($file); $audio->setFile($file);
$this->attributes['audio_id'] = (new MediaModel())->saveMedia($audio); $this->attributes['audio_id'] = new MediaModel()->saveMedia($audio);
} }
return $this; return $this;
@ -262,13 +281,14 @@ class Episode extends Entity
public function getAudio(): Audio public function getAudio(): Audio
{ {
if (! $this->audio instanceof 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; 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())) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this; return $this;
@ -279,18 +299,19 @@ class Episode extends Entity
->setFile($file); ->setFile($file);
$this->getTranscript() $this->getTranscript()
->updated_by = $this->attributes['updated_by']; ->updated_by = $this->attributes['updated_by'];
(new MediaModel('transcript'))->updateMedia($this->getTranscript()); new MediaModel('transcript')
->updateMedia($this->getTranscript());
} else { } else {
$transcript = new Transcript([ $transcript = new Transcript([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-transcript.' . $file->getExtension(), 'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-transcript.' . $file->getExtension(),
'language_code' => $this->getPodcast() 'language_code' => $this->getPodcast()
->language_code, ->language_code,
'uploaded_by' => $this->attributes['updated_by'], 'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'], 'updated_by' => $this->attributes['updated_by'],
]); ]);
$transcript->setFile($file); $transcript->setFile($file);
$this->attributes['transcript_id'] = (new MediaModel('transcript'))->saveMedia($transcript); $this->attributes['transcript_id'] = new MediaModel('transcript')->saveMedia($transcript);
} }
return $this; return $this;
@ -299,13 +320,14 @@ class Episode extends Entity
public function getTranscript(): ?Transcript public function getTranscript(): ?Transcript
{ {
if ($this->transcript_id !== null && ! $this->transcript instanceof 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; 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())) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this; return $this;
@ -316,18 +338,19 @@ class Episode extends Entity
->setFile($file); ->setFile($file);
$this->getChapters() $this->getChapters()
->updated_by = $this->attributes['updated_by']; ->updated_by = $this->attributes['updated_by'];
(new MediaModel('chapters'))->updateMedia($this->getChapters()); new MediaModel('chapters')
->updateMedia($this->getChapters());
} else { } else {
$chapters = new Chapters([ $chapters = new Chapters([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-chapters' . '.' . $file->getExtension(), 'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-chapters' . '.' . $file->getExtension(),
'language_code' => $this->getPodcast() 'language_code' => $this->getPodcast()
->language_code, ->language_code,
'uploaded_by' => $this->attributes['updated_by'], 'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'], 'updated_by' => $this->attributes['updated_by'],
]); ]);
$chapters->setFile($file); $chapters->setFile($file);
$this->attributes['chapters_id'] = (new MediaModel('chapters'))->saveMedia($chapters); $this->attributes['chapters_id'] = new MediaModel('chapters')->saveMedia($chapters);
} }
return $this; return $this;
@ -336,46 +359,13 @@ class Episode extends Entity
public function getChapters(): ?Chapters public function getChapters(): ?Chapters
{ {
if ($this->chapters_id !== null && ! $this->chapters instanceof 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; 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. * Gets transcript url from transcript file uri if it exists or returns the transcript_remote_url which can be null.
*/ */
@ -407,12 +397,9 @@ class Episode extends Entity
*/ */
public function getPersons(): array public function getPersons(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting persons.');
}
if ($this->persons === null) { 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; return $this->persons;
@ -425,12 +412,9 @@ class Episode extends Entity
*/ */
public function getSoundbites(): array public function getSoundbites(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting soundbites.');
}
if ($this->soundbites === null) { 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; return $this->soundbites;
@ -441,12 +425,9 @@ class Episode extends Entity
*/ */
public function getPosts(): array public function getPosts(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting posts.');
}
if ($this->posts === null) { if ($this->posts === null) {
$this->posts = (new PostModel())->getEpisodePosts($this->id); $this->posts = new PostModel()
->getEpisodePosts($this->id);
} }
return $this->posts; return $this->posts;
@ -457,23 +438,15 @@ class Episode extends Entity
*/ */
public function getComments(): array public function getComments(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting comments.');
}
if ($this->comments === null) { if ($this->comments === null) {
$this->comments = (new EpisodeCommentModel())->getEpisodeComments($this->id); $this->comments = new EpisodeCommentModel()
->getEpisodeComments($this->id);
} }
return $this->comments; return $this->comments;
} }
public function getLink(): string public function getEmbedUrl(?string $theme = null): string
{
return url_to('episode', esc($this->getPodcast()->handle), esc($this->attributes['slug']));
}
public function getEmbedUrl(string $theme = null): string
{ {
return $theme return $theme
? url_to('embed-theme', esc($this->getPodcast()->handle), esc($this->attributes['slug']), $theme) ? url_to('embed-theme', esc($this->getPodcast()->handle), esc($this->attributes['slug']), $theme)
@ -482,14 +455,15 @@ class Episode extends Entity
public function setGuid(?string $guid = null): static public function setGuid(?string $guid = null): static
{ {
$this->attributes['guid'] = $guid ?? $this->getLink(); $this->attributes['guid'] = $guid ?? $this->link;
return $this; return $this;
} }
public function getPodcast(): ?Podcast public function getPodcast(): ?Podcast
{ {
return (new PodcastModel())->getPodcastById($this->podcast_id); return new PodcastModel()
->getPodcastById($this->podcast_id);
} }
public function setDescriptionMarkdown(string $descriptionMarkdown): static public function setDescriptionMarkdown(string $descriptionMarkdown): static
@ -513,34 +487,6 @@ class Episode extends Entity
return $this; 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 public function getDescription(): string
{ {
if ($this->description === null) { if ($this->description === null) {
@ -609,96 +555,11 @@ class Episode extends Entity
return $this->location; 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 public function getPreviewLink(): string
{ {
if ($this->preview_id === null) { if ($this->preview_id === null) {
// generate preview id // 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'); throw new Exception('Could not set episode preview id');
} }
@ -713,10 +574,7 @@ class Episode extends Entity
*/ */
public function getClipCount(): int|string public function getClipCount(): int|string
{ {
if ($this->id === null) { return new ClipModel()
throw new RuntimeException('Episode must be created before getting number of video clips.'); ->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 Episode|null $episode
* @property int $actor_id * @property int $actor_id
* @property Actor|null $actor * @property Actor|null $actor
* @property string $in_reply_to_id * @property ?string $in_reply_to_id
* @property EpisodeComment|null $reply_to_comment * @property EpisodeComment|null $reply_to_comment
* @property string $message * @property string $message
* @property string $message_html * @property string $message_html
@ -75,12 +75,9 @@ class EpisodeComment extends UuidEntity
public function getEpisode(): ?Episode public function getEpisode(): ?Episode
{ {
if ($this->episode_id === null) {
throw new RuntimeException('Comment must have an episode_id before getting episode.');
}
if (! $this->episode instanceof 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; return $this->episode;
@ -91,10 +88,6 @@ class EpisodeComment extends UuidEntity
*/ */
public function getActor(): ?Actor public function getActor(): ?Actor
{ {
if ($this->actor_id === null) {
throw new RuntimeException('Comment must have an actor_id before getting actor.');
}
if (! $this->actor instanceof Actor) { if (! $this->actor instanceof Actor) {
$this->actor = model(ActorModel::class, false) $this->actor = model(ActorModel::class, false)
->getActorById($this->actor_id); ->getActorById($this->actor_id);
@ -108,12 +101,9 @@ class EpisodeComment extends UuidEntity
*/ */
public function getReplies(): array public function getReplies(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Comment must be created before getting replies.');
}
if ($this->replies === null) { if ($this->replies === null) {
$this->replies = (new EpisodeCommentModel())->getCommentReplies($this->id); $this->replies = new EpisodeCommentModel()
->getCommentReplies($this->id);
} }
return $this->replies; return $this->replies;

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace App\Entities; namespace App\Entities;
use CodeIgniter\Entity\Entity; use CodeIgniter\Entity\Entity;
use Config\Services;
/** /**
* @property string $url * @property string $url
@ -23,15 +22,9 @@ use Config\Services;
*/ */
class Location extends Entity class Location extends Entity
{ {
/** private const string OSM_URL = 'https://www.openstreetmap.org/';
* @var string
*/
private const OSM_URL = 'https://www.openstreetmap.org/';
/** private const string NOMINATIM_URL = 'https://nominatim.openstreetmap.org/';
* @var string
*/
private const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/';
public function __construct( public function __construct(
protected string $name, protected string $name,
@ -85,7 +78,7 @@ class Location extends Entity
*/ */
public function fetchOsmLocation(): static public function fetchOsmLocation(): static
{ {
$client = Services::curlrequest(); $client = service('curlrequest');
$response = $client->request( $response = $client->request(
'GET', 'GET',
@ -109,14 +102,14 @@ class Location extends Entity
if (property_exists($places[0], 'lat') && $places[0]->lat !== null && (property_exists( if (property_exists($places[0], 'lat') && $places[0]->lat !== null && (property_exists(
$places[0], $places[0],
'lon' 'lon',
) && $places[0]->lon !== null)) { ) && $places[0]->lon !== null)) {
$this->attributes['geo'] = "geo:{$places[0]->lat},{$places[0]->lon}"; $this->attributes['geo'] = "geo:{$places[0]->lat},{$places[0]->lon}";
} }
if (property_exists($places[0], 'osm_type') && $places[0]->osm_type !== null && (property_exists( if (property_exists($places[0], 'osm_type') && $places[0]->osm_type !== null && (property_exists(
$places[0], $places[0],
'osm_id' 'osm_id',
) && $places[0]->osm_id !== null)) { ) && $places[0]->osm_id !== null)) {
$this->attributes['osm'] = strtoupper(substr((string) $places[0]->osm_type, 0, 1)) . $places[0]->osm_id; $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 $full_name
* @property string $unique_name * @property string $unique_name
* @property string|null $information_url * @property string|null $information_url
* @property int $avatar_id * @property ?int $avatar_id
* @property ?Image $avatar * @property ?Image $avatar
* @property int $created_by * @property int $created_by
* @property int $updated_by * @property int $updated_by
@ -56,7 +56,7 @@ class Person extends Entity
/** /**
* Saves the person avatar in `public/media/persons/` * 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())) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this; return $this;
@ -67,18 +67,19 @@ class Person extends Entity
->setFile($file); ->setFile($file);
$this->getAvatar() $this->getAvatar()
->updated_by = $this->attributes['updated_by']; ->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getAvatar()); new MediaModel('image')
->updateMedia($this->getAvatar());
} else { } else {
$avatar = new Image([ $avatar = new Image([
'file_key' => 'persons/' . $this->attributes['unique_name'] . '.' . $file->getExtension(), 'file_key' => 'persons/' . $this->attributes['unique_name'] . '.' . $file->getExtension(),
'sizes' => config('Images') 'sizes' => config('Images')
->personAvatarSizes, ->personAvatarSizes,
'uploaded_by' => $this->attributes['updated_by'], 'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'], 'updated_by' => $this->attributes['updated_by'],
]); ]);
$avatar->setFile($file); $avatar->setFile($file);
$this->attributes['avatar_id'] = (new MediaModel('image'))->saveMedia($avatar); $this->attributes['avatar_id'] = new MediaModel('image')->saveMedia($avatar);
} }
return $this; return $this;
@ -91,7 +92,8 @@ class Person extends Entity
} }
if (! $this->avatar instanceof Image) { 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; return $this->avatar;
@ -107,10 +109,11 @@ class Person extends Entity
} }
if ($this->roles === null) { if ($this->roles === null) {
$this->roles = (new PersonModel())->getPersonRoles( $this->roles = new PersonModel()
->getPersonRoles(
$this->id, $this->id,
(int) $this->attributes['podcast_id'], (int) $this->attributes['podcast_id'],
array_key_exists('episode_id', $this->attributes) ? (int) $this->attributes['episode_id'] : null array_key_exists('episode_id', $this->attributes) ? (int) $this->attributes['episode_id'] : null,
); );
} }

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace App\Entities; namespace App\Entities;
use App\Libraries\SimpleRSSElement;
use App\Models\ActorModel; use App\Models\ActorModel;
use App\Models\CategoryModel; use App\Models\CategoryModel;
use App\Models\EpisodeModel; use App\Models\EpisodeModel;
@ -62,12 +61,8 @@ use RuntimeException;
* @property string|null $publisher * @property string|null $publisher
* @property string $owner_name * @property string $owner_name
* @property string $owner_email * @property string $owner_email
* @property bool $is_owner_email_removed_from_feed
* @property string $type * @property string $type
* @property string $medium
* @property string|null $copyright * @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_blocked
* @property bool $is_completed * @property bool $is_completed
* @property bool $is_locked * @property bool $is_locked
@ -77,15 +72,7 @@ use RuntimeException;
* @property string|null $location_name * @property string|null $location_name
* @property string|null $location_geo * @property string|null $location_geo
* @property string|null $location_osm * @property string|null $location_osm
* @property string|null $payment_pointer
* @property array|null $custom_rss
* @property bool $is_op3_enabled
* @property string $op3_url
* @property string $custom_rss_string
* @property bool $is_published_on_hubs * @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 $created_by
* @property int $updated_by * @property int $updated_by
* @property string $publication_status * @property string $publication_status
@ -125,9 +112,9 @@ class Podcast extends Entity
protected ?array $other_categories = null; protected ?array $other_categories = null;
/** /**
* @var string[]|null * @var int[]
*/ */
protected ?array $other_categories_ids = null; protected array $other_categories_ids = [];
/** /**
* @var Episode[]|null * @var Episode[]|null
@ -166,8 +153,6 @@ class Podcast extends Entity
protected ?Location $location = null; protected ?Location $location = null;
protected string $custom_rss_string;
protected ?string $publication_status = null; protected ?string $publication_status = null;
/** /**
@ -195,12 +180,8 @@ class Podcast extends Entity
'publisher' => '?string', 'publisher' => '?string',
'owner_name' => 'string', 'owner_name' => 'string',
'owner_email' => 'string', 'owner_email' => 'string',
'is_owner_email_removed_from_feed' => 'boolean',
'type' => 'string', 'type' => 'string',
'medium' => 'string',
'copyright' => '?string', 'copyright' => '?string',
'episode_description_footer_markdown' => '?string',
'episode_description_footer_html' => '?string',
'is_blocked' => 'boolean', 'is_blocked' => 'boolean',
'is_completed' => 'boolean', 'is_completed' => 'boolean',
'is_locked' => 'boolean', 'is_locked' => 'boolean',
@ -210,12 +191,7 @@ class Podcast extends Entity
'location_name' => '?string', 'location_name' => '?string',
'location_geo' => '?string', 'location_geo' => '?string',
'location_osm' => '?string', 'location_osm' => '?string',
'payment_pointer' => '?string',
'custom_rss' => '?json-array',
'is_published_on_hubs' => 'boolean', 'is_published_on_hubs' => 'boolean',
'partner_id' => '?string',
'partner_link_url' => '?string',
'partner_image_url' => '?string',
'created_by' => 'integer', 'created_by' => 'integer',
'updated_by' => 'integer', 'updated_by' => 'integer',
]; ];
@ -239,7 +215,7 @@ class Podcast extends Entity
return $this->actor; 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())) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this; return $this;
@ -250,18 +226,19 @@ class Podcast extends Entity
->setFile($file); ->setFile($file);
$this->getCover() $this->getCover()
->updated_by = $this->attributes['updated_by']; ->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getCover()); new MediaModel('image')
->updateMedia($this->getCover());
} else { } else {
$cover = new Image([ $cover = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/cover.' . $file->getExtension(), 'file_key' => 'podcasts/' . $this->attributes['handle'] . '/cover.' . $file->getExtension(),
'sizes' => config('Images') 'sizes' => config('Images')
->podcastCoverSizes, ->podcastCoverSizes,
'uploaded_by' => $this->attributes['updated_by'], 'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'], 'updated_by' => $this->attributes['updated_by'],
]); ]);
$cover->setFile($file); $cover->setFile($file);
$this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover); $this->attributes['cover_id'] = new MediaModel('image')->saveMedia($cover);
} }
return $this; return $this;
@ -270,7 +247,8 @@ class Podcast extends Entity
public function getCover(): Image public function getCover(): Image
{ {
if (! $this->cover instanceof 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) { if (! $cover instanceof Image) {
throw new Exception('Could not retrieve podcast cover.'); throw new Exception('Could not retrieve podcast cover.');
@ -282,7 +260,7 @@ class Podcast extends Entity
return $this->cover; 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())) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this; return $this;
@ -293,18 +271,19 @@ class Podcast extends Entity
->setFile($file); ->setFile($file);
$this->getBanner() $this->getBanner()
->updated_by = $this->attributes['updated_by']; ->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getBanner()); new MediaModel('image')
->updateMedia($this->getBanner());
} else { } else {
$banner = new Image([ $banner = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/banner.' . $file->getExtension(), 'file_key' => 'podcasts/' . $this->attributes['handle'] . '/banner.' . $file->getExtension(),
'sizes' => config('Images') 'sizes' => config('Images')
->podcastBannerSizes, ->podcastBannerSizes,
'uploaded_by' => $this->attributes['updated_by'], 'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'], 'updated_by' => $this->attributes['updated_by'],
]); ]);
$banner->setFile($file); $banner->setFile($file);
$this->attributes['banner_id'] = (new MediaModel('image'))->saveMedia($banner); $this->attributes['banner_id'] = new MediaModel('image')->saveMedia($banner);
} }
return $this; return $this;
@ -317,7 +296,8 @@ class Podcast extends Entity
} }
if (! $this->banner instanceof Image) { 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; return $this->banner;
@ -340,12 +320,9 @@ class Podcast extends Entity
*/ */
public function getEpisodes(): array public function getEpisodes(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting episodes.');
}
if ($this->episodes === null) { 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; return $this->episodes;
@ -356,11 +333,8 @@ class Podcast extends Entity
*/ */
public function getEpisodesCount(): int|string public function getEpisodesCount(): int|string
{ {
if ($this->id === null) { return new EpisodeModel()
throw new RuntimeException('Podcast must be created before getting number of episodes.'); ->getPodcastEpisodesCount($this->id);
}
return (new EpisodeModel())->getPodcastEpisodesCount($this->id);
} }
/** /**
@ -370,12 +344,9 @@ class Podcast extends Entity
*/ */
public function getPersons(): array public function getPersons(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting persons.');
}
if ($this->persons === null) { if ($this->persons === null) {
$this->persons = (new PersonModel())->getPodcastPersons($this->id); $this->persons = new PersonModel()
->getPodcastPersons($this->id);
} }
return $this->persons; return $this->persons;
@ -386,12 +357,9 @@ class Podcast extends Entity
*/ */
public function getCategory(): ?Category public function getCategory(): ?Category
{ {
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting category.');
}
if (! $this->category instanceof 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; return $this->category;
@ -404,12 +372,9 @@ class Podcast extends Entity
*/ */
public function getSubscriptions(): array public function getSubscriptions(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Podcasts must be created before getting subscriptions.');
}
if ($this->subscriptions === null) { if ($this->subscriptions === null) {
$this->subscriptions = (new SubscriptionModel())->getPodcastSubscriptions($this->id); $this->subscriptions = new SubscriptionModel()
->getPodcastSubscriptions($this->id);
} }
return $this->subscriptions; return $this->subscriptions;
@ -422,12 +387,9 @@ class Podcast extends Entity
*/ */
public function getContributors(): array public function getContributors(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Podcasts must be created before getting contributors.');
}
if ($this->contributors === null) { if ($this->contributors === null) {
$this->contributors = (new UserModel())->getPodcastContributors($this->id); $this->contributors = new UserModel()
->getPodcastContributors($this->id);
} }
return $this->contributors; return $this->contributors;
@ -454,42 +416,6 @@ class Podcast extends Entity
return $this; 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 public function getDescription(): string
{ {
if ($this->description === null) { if ($this->description === null) {
@ -523,12 +449,9 @@ class Podcast extends Entity
*/ */
public function getPodcastingPlatforms(): array public function getPodcastingPlatforms(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting podcasting platform links.');
}
if ($this->podcasting_platforms === null) { 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; return $this->podcasting_platforms;
@ -541,12 +464,9 @@ class Podcast extends Entity
*/ */
public function getSocialPlatforms(): array public function getSocialPlatforms(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting social platform links.');
}
if ($this->social_platforms === null) { 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; return $this->social_platforms;
@ -559,12 +479,9 @@ class Podcast extends Entity
*/ */
public function getFundingPlatforms(): array public function getFundingPlatforms(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting funding platform links.');
}
if ($this->funding_platforms === null) { 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; return $this->funding_platforms;
@ -575,23 +492,20 @@ class Podcast extends Entity
*/ */
public function getOtherCategories(): array public function getOtherCategories(): array
{ {
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting other categories.');
}
if ($this->other_categories === null) { 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 $this->other_categories;
} }
/** /**
* @return int[]|string[] * @return int[]
*/ */
public function getOtherCategoriesIds(): array public function getOtherCategoriesIds(): array
{ {
if ($this->other_categories_ids === null) { if ($this->other_categories_ids === []) {
$this->other_categories_ids = array_column($this->getOtherCategories(), 'id'); $this->other_categories_ids = array_column($this->getOtherCategories(), 'id');
} }
@ -638,68 +552,10 @@ class Podcast extends Entity
return $this->location; 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 public function getIsPremium(): bool
{ {
// podcast is premium if at least one of its episodes is set as premium // podcast is premium if at least one of its episodes is set as premium
return (new EpisodeModel())->doesPodcastHavePremiumEpisodes($this->id); 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;
} }
} }

View file

@ -34,6 +34,7 @@ class Post extends FediversePost
'episode_id' => '?integer', 'episode_id' => '?integer',
'message' => 'string', 'message' => 'string',
'message_html' => 'string', 'message_html' => 'string',
'is_private' => 'boolean',
'favourites_count' => 'integer', 'favourites_count' => 'integer',
'reblogs_count' => 'integer', 'reblogs_count' => 'integer',
'replies_count' => 'integer', 'replies_count' => 'integer',
@ -50,7 +51,8 @@ class Post extends FediversePost
} }
if (! $this->episode instanceof 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; return $this->episode;

View file

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

View file

@ -2,14 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use Config\Services;
if (! function_exists('render_breadcrumb')) { if (! function_exists('render_breadcrumb')) {
/** /**
* Renders the breadcrumb navigation through the Breadcrumb service * Renders the breadcrumb navigation through the Breadcrumb service
@ -17,20 +9,18 @@ if (! function_exists('render_breadcrumb')) {
* @param string|null $class to be added to the breadcrumb nav * @param string|null $class to be added to the breadcrumb nav
* @return string html breadcrumb * @return string html breadcrumb
*/ */
function render_breadcrumb(string $class = null): string function render_breadcrumb(?string $class = null): string
{ {
$breadcrumb = Services::breadcrumb(); return service('breadcrumb')->render($class);
return $breadcrumb->render($class);
} }
} }
if (! function_exists('replace_breadcrumb_params')) { if (! function_exists('replace_breadcrumb_params')) {
/** /**
* @param string[] $newParams * @param array<string|int,string> $newParams
*/ */
function replace_breadcrumb_params(array $newParams): void function replace_breadcrumb_params(array $newParams): void
{ {
$breadcrumb = Services::breadcrumb(); service('breadcrumb')->replaceParams($newParams);
$breadcrumb->replaceParams(esc($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')) { if (! function_exists('data_table')) {
/** /**
* Data table component * Data table component
@ -113,12 +88,12 @@ if (! function_exists('publication_pill')) {
*/ */
function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string
{ {
$class = match ($publicationStatus) { $variant = match ($publicationStatus) {
'published' => 'text-pine-500 border-pine-500 bg-pine-50', 'published' => 'success',
'scheduled' => 'text-red-600 border-red-600 bg-red-50', 'scheduled' => 'warning',
'with_podcast' => 'text-blue-600 border-blue-600 bg-blue-50', 'with_podcast' => 'info',
'not_published' => 'text-gray-600 border-gray-600 bg-gray-50', 'not_published' => 'default',
default => 'text-gray-600 border-gray-600 bg-gray-50', default => 'default',
}; };
$title = match ($publicationStatus) { $title = match ($publicationStatus) {
@ -130,16 +105,12 @@ if (! function_exists('publication_pill')) {
$label = lang('Episode.publication_status.' . $publicationStatus); $label = lang('Episode.publication_status.' . $publicationStatus);
return '<span ' . ($title === '' ? '' : 'title="' . $title . '"') . ' class="flex items-center px-1 font-semibold border rounded w-max ' . // @icon("error-warning-fill")
$class . return '<x-Pill ' . ($title === '' ? '' : 'title="' . $title . '"') . ' variant="' . $variant . '" class="' . $customClass .
' ' . '">' . $label . ($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
$customClass .
'">' .
$label .
($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
'class' => 'flex-shrink-0 ml-1 text-lg', 'class' => 'flex-shrink-0 ml-1 text-lg',
]) : '') . ]) : '') .
'</span>'; '</x-Pill>';
} }
} }
@ -158,20 +129,20 @@ if (! function_exists('publication_button')) {
$label = lang('Episode.publish'); $label = lang('Episode.publish');
$route = route_to('episode-publish', $podcastId, $episodeId); $route = route_to('episode-publish', $podcastId, $episodeId);
$variant = 'primary'; $variant = 'primary';
$iconLeft = 'upload-cloud-fill'; // @icon('upload-cloud-fill') $iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
break; break;
case 'with_podcast': case 'with_podcast':
case 'scheduled': case 'scheduled':
$label = lang('Episode.publish_edit'); $label = lang('Episode.publish_edit');
$route = route_to('episode-publish_edit', $podcastId, $episodeId); $route = route_to('episode-publish_edit', $podcastId, $episodeId);
$variant = 'warning'; $variant = 'warning';
$iconLeft = 'upload-cloud-fill'; // @icon('upload-cloud-fill') $iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
break; break;
case 'published': case 'published':
$label = lang('Episode.unpublish'); $label = lang('Episode.unpublish');
$route = route_to('episode-unpublish', $podcastId, $episodeId); $route = route_to('episode-unpublish', $podcastId, $episodeId);
$variant = 'danger'; $variant = 'danger';
$iconLeft = 'cloud-off-fill'; // @icon('cloud-off-fill') $iconLeft = 'cloud-off-fill'; // @icon("cloud-off-fill")
break; break;
default: default:
$label = ''; $label = '';
@ -182,7 +153,7 @@ if (! function_exists('publication_button')) {
} }
return <<<HTML return <<<HTML
<Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</Button> <x-Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</x-Button>
HTML; HTML;
} }
} }
@ -263,7 +234,7 @@ if (! function_exists('episode_publication_status_banner')) {
$bannerText = lang('Episode.publication_status_banner.text', [ $bannerText = lang('Episode.publication_status_banner.text', [
'publication_status' => $episode->publication_status, 'publication_status' => $episode->publication_status,
'publication_date' => $episode->published_at instanceof Time ? local_datetime( 'publication_date' => $episode->published_at instanceof Time ? local_datetime(
$episode->published_at $episode->published_at,
) : null, ) : null,
]); ]);
$previewLinkLabel = lang('Episode.publication_status_banner.preview'); $previewLinkLabel = lang('Episode.publication_status_banner.preview');
@ -296,7 +267,7 @@ if (! function_exists('episode_numbering')) {
?int $episodeNumber = null, ?int $episodeNumber = null,
?int $seasonNumber = null, ?int $seasonNumber = null,
string $class = '', string $class = '',
bool $isAbbr = false bool $isAbbr = false,
): string { ): string {
if (! $episodeNumber && ! $seasonNumber) { if (! $episodeNumber && ! $seasonNumber) {
return ''; return '';
@ -356,7 +327,7 @@ if (! function_exists('location_link')) {
'class' => 'mr-2 flex-shrink-0', 'class' => 'mr-2 flex-shrink-0',
]) . '<span class="truncate">' . esc($location->name) . '</span>', ]) . '<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}"), ($class === '' ? '' : " {$class}"),
'target' => '_blank', 'target' => '_blank',
'rel' => 'noreferrer noopener', 'rel' => 'noreferrer noopener',
@ -381,15 +352,15 @@ if (! function_exists('audio_player')) {
id="castopod-vm-player" id="castopod-vm-player"
theme="light" theme="light"
language="{$language}" language="{$language}"
icons="castopod-icons"
class="{$class} relative z-0" class="{$class} relative z-0"
icons="castopod-vm-player-icons"
style="--vm-player-box-shadow:0; --vm-player-theme: hsl(var(--color-accent-base)); --vm-control-focus-color: hsl(var(--color-accent-contrast)); --vm-control-spacing: 4px; --vm-menu-item-focus-bg: hsl(var(--color-background-highlight));" style="--vm-player-box-shadow:0; --vm-player-theme: hsl(var(--color-accent-base)); --vm-control-focus-color: hsl(var(--color-accent-contrast)); --vm-control-spacing: 4px; --vm-menu-item-focus-bg: hsl(var(--color-background-highlight));"
> >
<vm-audio preload="none"> <vm-audio preload="none">
<source src="{$source}" type="{$mediaType}" /> <source src="{$source}" type="{$mediaType}" />
</vm-audio> </vm-audio>
<vm-ui> <vm-ui>
<vm-icon-library name="castopod-icons"></vm-icon-library> <vm-icon-library name="castopod-vm-player-icons"></vm-icon-library>
<vm-controls full-width> <vm-controls full-width>
<vm-playback-control></vm-playback-control> <vm-playback-control></vm-playback-control>
<vm-volume-control></vm-volume-control> <vm-volume-control></vm-volume-control>
@ -411,7 +382,7 @@ if (! function_exists('relative_time')) {
function relative_time(Time $time, string $class = ''): string function relative_time(Time $time, string $class = ''): string
{ {
$formatter = new IntlDateFormatter(service( $formatter = new IntlDateFormatter(service(
'request' 'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE); )->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
$translatedDate = $time->toLocalizedString($formatter->getPattern()); $translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ATOM); $datetime = $time->format(DateTime::ATOM);
@ -432,7 +403,7 @@ if (! function_exists('local_datetime')) {
function local_datetime(Time $time): string function local_datetime(Time $time): string
{ {
$formatter = new IntlDateFormatter(service( $formatter = new IntlDateFormatter(service(
'request' 'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG); )->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG);
$translatedDate = $time->toLocalizedString($formatter->getPattern()); $translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ATOM); $datetime = $time->format(DateTime::ATOM);
@ -461,7 +432,7 @@ if (! function_exists('local_date')) {
function local_date(Time $time): string function local_date(Time $time): string
{ {
$formatter = new IntlDateFormatter(service( $formatter = new IntlDateFormatter(service(
'request' 'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE); )->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
$translatedDate = $time->toLocalizedString($formatter->getPattern()); $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. // 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( if ((is_array($extra) && array_key_exists('rows', $extra)) || (is_string($extra) && stripos(
(string) preg_replace('~\s+~', '', $extra), (string) preg_replace('~\s+~', '', $extra),
'rows=' 'rows=',
) !== false)) { ) !== false)) {
unset($defaults['rows']); unset($defaults['rows']);
} }
if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos( if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos(
(string) preg_replace('~\s+~', '', $extra), (string) preg_replace('~\s+~', '', $extra),
'cols=' 'cols=',
) !== false)) { ) !== false)) {
unset($defaults['cols']); unset($defaults['cols']);
} }
return '<textarea ' . rtrim(parse_form_attributes($data, $defaults)) . stringify_attributes( return '<textarea ' . rtrim(parse_form_attributes($data, $defaults)) . stringify_attributes(
$extra $extra,
) . '>' . $val . "</textarea>\n"; ) . '>' . $val . "</textarea>\n";
} }
} }

View file

@ -206,18 +206,18 @@ if (! function_exists('get_podcast_banner')) {
if (! $podcast->banner instanceof Image) { if (! $podcast->banner instanceof Image) {
$defaultBanner = config('Images') $defaultBanner = config('Images')
->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config( ->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config(
Images::class Images::class,
)->podcastBannerDefaultPaths['default']; )->podcastBannerDefaultPaths['default'];
$sizes = config('Images') $sizes = config('Images')
->podcastBannerSizes; ->podcastBannerSizes;
$sizeConfig = $sizes[$size]; $sizeConfig = $sizes[$size];
helper('filesystem'); helper('filesystem');
// return default site icon url // return default site icon url
return base_url( return base_url(
change_file_path($defaultBanner['path'], '_' . $size, $sizeConfig['extension'] ?? null) change_file_path($defaultBanner['path'], '_' . $size, $sizeConfig['extension'] ?? null),
); );
} }
@ -231,14 +231,14 @@ if (! function_exists('get_podcast_banner_mimetype')) {
{ {
if (! $podcast->banner instanceof Image) { if (! $podcast->banner instanceof Image) {
$sizes = config('Images') $sizes = config('Images')
->podcastBannerSizes; ->podcastBannerSizes;
$sizeConfig = $sizes[$size]; $sizeConfig = $sizes[$size];
helper('filesystem'); helper('filesystem');
// return default site icon url // return default site icon url
return array_key_exists('mimetype', $sizeConfig) ? $sizeConfig['mimetype'] : config( return array_key_exists('mimetype', $sizeConfig) ? $sizeConfig['mimetype'] : config(
Images::class Images::class,
)->podcastBannerDefaultMimeType; )->podcastBannerDefaultMimeType;
} }
@ -252,10 +252,10 @@ if (! function_exists('get_avatar_url')) {
{ {
if (! $person->avatar instanceof Image) { if (! $person->avatar instanceof Image) {
$defaultAvatarPath = config('Images') $defaultAvatarPath = config('Images')
->avatarDefaultPath; ->avatarDefaultPath;
$sizes = config('Images') $sizes = config('Images')
->personAvatarSizes; ->personAvatarSizes;
$sizeConfig = $sizes[$size]; $sizeConfig = $sizes[$size];

View file

@ -16,34 +16,35 @@ if (! function_exists('render_page_links')) {
* *
* @return string html pages navigation * @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'), [ $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) { if ($podcastHandle !== null) {
$links .= anchor(route_to('podcast-links', $podcastHandle), lang('Podcast.links'), [ $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'), [ $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'), [ $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) { foreach ($pages as $page) {
$links .= anchor($page->link, esc($page->title), [ $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 set in .env, add legal notice link at the end of page links
if (config('App')->legalNoticeURL !== null) { if (config('App')->legalNoticeURL !== null) {
$links .= anchor(config('App')->legalNoticeURL, lang('Common.legal_notice'), [ $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', 'target' => '_blank',
'rel' => 'noopener noreferrer', 'rel' => 'noopener noreferrer',
]); ]);

View file

@ -10,12 +10,13 @@ declare(strict_types=1);
use App\Entities\Category; use App\Entities\Category;
use App\Entities\Location; use App\Entities\Location;
use App\Entities\Podcast; use App\Entities\Podcast;
use App\Libraries\SimpleRSSElement; use App\Libraries\RssFeed;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
use Config\Mimes; use Config\Mimes;
use Modules\Media\Entities\Chapters; use Modules\Media\Entities\Chapters;
use Modules\Media\Entities\Transcript; use Modules\Media\Entities\Transcript;
use Modules\Plugins\Core\Plugins;
use Modules\PremiumPodcasts\Entities\Subscription; use Modules\PremiumPodcasts\Entities\Subscription;
if (! function_exists('get_rss_feed')) { if (! function_exists('get_rss_feed')) {
@ -28,24 +29,21 @@ if (! function_exists('get_rss_feed')) {
function get_rss_feed( function get_rss_feed(
Podcast $podcast, Podcast $podcast,
string $serviceSlug = '', string $serviceSlug = '',
Subscription $subscription = null, ?Subscription $subscription = null,
string $token = null ?string $token = null,
): string { ): string {
/** @var Plugins $plugins */
$plugins = service('plugins');
$episodes = $podcast->episodes; $episodes = $podcast->episodes;
$itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd'; $rss = new RssFeed();
$podcastNamespace = 'https://podcastindex.org/namespace/1.0'; $plugins->rssBeforeChannel($podcast);
$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>"
);
$channel = $rss->addChild('channel'); $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('href', $podcast->feed_url);
$atomLink->addAttribute('rel', 'self'); $atomLink->addAttribute('rel', 'self');
$atomLink->addAttribute('type', 'application/rss+xml'); $atomLink->addAttribute('type', 'application/rss+xml');
@ -54,18 +52,18 @@ if (! function_exists('get_rss_feed')) {
$websubHubs = config('WebSub') $websubHubs = config('WebSub')
->hubs; ->hubs;
foreach ($websubHubs as $websubHub) { foreach ($websubHubs as $websubHub) {
$atomLinkHub = $channel->addChild('link', null, $atomNamespace); $atomLinkHub = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLinkHub->addAttribute('href', $websubHub); $atomLinkHub->addAttribute('href', $websubHub);
$atomLinkHub->addAttribute('rel', 'hub'); $atomLinkHub->addAttribute('rel', 'hub');
$atomLinkHub->addAttribute('type', 'application/rss+xml'); $atomLinkHub->addAttribute('type', 'application/rss+xml');
} }
if ($podcast->new_feed_url !== null) { 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 // 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('generator', 'Castopod - https://castopod.org/');
$channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html'); $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) $podcast->guid = $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $podcast->feed_url)
->toString(); ->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->addChild('title', $podcast->title, null, false);
$channel->addChildWithCDATA('description', $podcast->description_html); $channel->addChildWithCDATA('description', $podcast->description_html);
$channel->addChild('medium', $podcast->medium, $podcastNamespace); $itunesImage = $channel->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
$itunesImage = $channel->addChild('image', null, $itunesNamespace);
$itunesImage->addAttribute('href', $podcast->cover->feed_url); $itunesImage->addAttribute('href', $podcast->cover->feed_url);
$channel->addChild('language', $podcast->language_code); $channel->addChild('language', $podcast->language_code);
if ($podcast->location instanceof Location) { 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) { if ($podcast->location->geo !== null) {
$locationElement->addAttribute('geo', $podcast->location->geo); $locationElement->addAttribute('geo', $podcast->location->geo);
} }
@ -101,49 +98,25 @@ 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 $channel
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', $podcastNamespace); ->addChild('locked', $podcast->is_locked ? 'yes' : 'no', RssFeed::PODCAST_NAMESPACE)
} else {
$channel
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', $podcastNamespace)
->addAttribute('owner', $podcast->owner_email); ->addAttribute('owner', $podcast->owner_email);
}
if ($podcast->verify_txt !== null) {
$channel
->addChild('txt', $podcast->verify_txt, $podcastNamespace)
->addAttribute('purpose', 'verify');
}
if ($podcast->imported_feed_url !== null) { 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) { 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); $podcastingPlatformElement->addAttribute('platform', $podcastingPlatform->slug);
if ($podcastingPlatform->account_id !== null) { if ($podcastingPlatform->account_id !== null) {
$podcastingPlatformElement->addAttribute('id', $podcastingPlatform->account_id); $podcastingPlatformElement->addAttribute('id', $podcastingPlatform->account_id);
} }
if ($podcastingPlatform->link_url !== null) {
$podcastingPlatformElement->addAttribute('url', $podcastingPlatform->link_url); $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('priority', '1');
$castopodSocialElement->addAttribute('platform', 'castopod'); $castopodSocialElement->addAttribute('platform', 'castopod');
$castopodSocialElement->addAttribute('protocol', 'activitypub'); $castopodSocialElement->addAttribute('protocol', 'activitypub');
@ -151,7 +124,7 @@ if (! function_exists('get_rss_feed')) {
$castopodSocialElement->addAttribute('accountUrl', $podcast->link); $castopodSocialElement->addAttribute('accountUrl', $podcast->link);
foreach ($podcast->social_platforms as $socialPlatform) { 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('priority', '2');
$socialElement->addAttribute('platform', $socialPlatform->slug); $socialElement->addAttribute('platform', $socialPlatform->slug);
@ -159,7 +132,7 @@ if (! function_exists('get_rss_feed')) {
if (in_array( if (in_array(
$socialPlatform->slug, $socialPlatform->slug,
['mastodon', 'peertube', 'funkwhale', 'misskey', 'mobilizon', 'pixelfed', 'plume', 'writefreely'], ['mastodon', 'peertube', 'funkwhale', 'misskey', 'mobilizon', 'pixelfed', 'plume', 'writefreely'],
true true,
)) { )) {
$socialElement->addAttribute('protocol', 'activitypub'); $socialElement->addAttribute('protocol', 'activitypub');
} else { } else {
@ -170,46 +143,44 @@ if (! function_exists('get_rss_feed')) {
$socialElement->addAttribute('accountId', esc($socialPlatform->account_id)); $socialElement->addAttribute('accountId', esc($socialPlatform->account_id));
} }
if ($socialPlatform->link_url !== null) {
$socialElement->addAttribute('accountUrl', esc($socialPlatform->link_url)); $socialElement->addAttribute('accountUrl', esc($socialPlatform->link_url));
}
if ($socialPlatform->slug === 'mastodon') { if ($socialPlatform->slug === 'mastodon') {
$socialSignUpelement = $socialElement->addChild('socialSignUp', null, $podcastNamespace); $socialSignUpelement = $socialElement->addChild('socialSignUp', null, RssFeed::PODCAST_NAMESPACE);
$socialSignUpelement->addAttribute('priority', '1'); $socialSignUpelement->addAttribute('priority', '1');
$socialSignUpelement->addAttribute( $socialSignUpelement->addAttribute(
'homeUrl', 'homeUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url( parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url, (string) $socialPlatform->link_url,
PHP_URL_HOST PHP_URL_HOST,
) . '/public' ) . '/public',
); );
$socialSignUpelement->addAttribute( $socialSignUpelement->addAttribute(
'signUpUrl', 'signUpUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url( parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url, (string) $socialPlatform->link_url,
PHP_URL_HOST PHP_URL_HOST,
) . '/auth/sign_up' ) . '/auth/sign_up',
); );
$castopodSocialSignUpelement = $castopodSocialElement->addChild( $castopodSocialSignUpelement = $castopodSocialElement->addChild(
'socialSignUp', 'socialSignUp',
null, null,
$podcastNamespace RssFeed::PODCAST_NAMESPACE,
); );
$castopodSocialSignUpelement->addAttribute('priority', '1'); $castopodSocialSignUpelement->addAttribute('priority', '1');
$castopodSocialSignUpelement->addAttribute( $castopodSocialSignUpelement->addAttribute(
'homeUrl', 'homeUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url( parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url, (string) $socialPlatform->link_url,
PHP_URL_HOST PHP_URL_HOST,
) . '/public' ) . '/public',
); );
$castopodSocialSignUpelement->addAttribute( $castopodSocialSignUpelement->addAttribute(
'signUpUrl', 'signUpUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url( parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url, (string) $socialPlatform->link_url,
PHP_URL_HOST PHP_URL_HOST,
) . '/auth/sign_up' ) . '/auth/sign_up',
); );
} }
} }
@ -218,17 +189,15 @@ if (! function_exists('get_rss_feed')) {
$fundingPlatformElement = $channel->addChild( $fundingPlatformElement = $channel->addChild(
'funding', 'funding',
$fundingPlatform->account_id, $fundingPlatform->account_id,
$podcastNamespace, RssFeed::PODCAST_NAMESPACE,
); );
$fundingPlatformElement->addAttribute('platform', $fundingPlatform->slug); $fundingPlatformElement->addAttribute('platform', $fundingPlatform->slug);
if ($fundingPlatform->link_url !== null) {
$fundingPlatformElement->addAttribute('url', $fundingPlatform->link_url); $fundingPlatformElement->addAttribute('url', $fundingPlatform->link_url);
} }
}
foreach ($podcast->persons as $person) { foreach ($podcast->persons as $person) {
foreach ($person->roles as $role) { 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, 'medium')); $personElement->addAttribute('img', get_avatar_url($person, 'medium'));
@ -257,29 +226,26 @@ if (! function_exists('get_rss_feed')) {
$channel->addChild( $channel->addChild(
'explicit', 'explicit',
$podcast->parental_advisory === 'explicit' ? 'true' : 'false', $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); $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) { $channel->addChild('type', $podcast->type, RssFeed::ITUNES_NAMESPACE);
$owner->addChild('email', $podcast->owner_email, $itunesNamespace);
}
$channel->addChild('type', $podcast->type, $itunesNamespace);
$podcast->copyright && $podcast->copyright &&
$channel->addChild('copyright', $podcast->copyright); $channel->addChild('copyright', $podcast->copyright);
if ($podcast->is_blocked || $subscription instanceof Subscription) { if ($podcast->is_blocked || $subscription instanceof Subscription) {
$channel->addChild('block', 'Yes', $itunesNamespace); $channel->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
} }
if ($podcast->is_completed) { if ($podcast->is_completed) {
$channel->addChild('complete', 'Yes', $itunesNamespace); $channel->addChild('complete', 'Yes', RssFeed::ITUNES_NAMESPACE);
} }
$image = $channel->addChild('image'); $image = $channel->addChild('image');
@ -287,17 +253,16 @@ if (! function_exists('get_rss_feed')) {
$image->addChild('title', $podcast->title, null, false); $image->addChild('title', $podcast->title, null, false);
$image->addChild('link', $podcast->link); $image->addChild('link', $podcast->link);
if ($podcast->custom_rss !== null) { // run plugins hook at the end
array_to_rss([ $plugins->rssAfterChannel($podcast, $channel);
'elements' => $podcast->custom_rss,
], $channel);
}
foreach ($episodes as $episode) { foreach ($episodes as $episode) {
if ($episode->is_premium && ! $subscription instanceof Subscription) { if ($episode->is_premium && ! $subscription instanceof Subscription) {
continue; continue;
} }
$plugins->rssBeforeItem($episode);
$item = $channel->addChild('item'); $item = $channel->addChild('item');
$item->addChild('title', $episode->title, null, false); $item->addChild('title', $episode->title, null, false);
$enclosure = $item->addChild('enclosure'); $enclosure = $item->addChild('enclosure');
@ -317,7 +282,7 @@ if (! function_exists('get_rss_feed')) {
$item->addChild('guid', $episode->guid); $item->addChild('guid', $episode->guid);
$item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123)); $item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123));
if ($episode->location instanceof Location) { 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) { if ($episode->location->geo !== null) {
$locationElement->addAttribute('geo', $episode->location->geo); $locationElement->addAttribute('geo', $episode->location->geo);
} }
@ -327,10 +292,10 @@ if (! function_exists('get_rss_feed')) {
} }
} }
$item->addChildWithCDATA('description', $episode->getDescriptionHtml($serviceSlug)); $item->addChildWithCDATA('description', $episode->description_html);
$item->addChild('duration', (string) round($episode->audio->duration), $itunesNamespace); $item->addChild('duration', (string) round($episode->audio->duration), RssFeed::ITUNES_NAMESPACE);
$item->addChild('link', $episode->link); $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); $episodeItunesImage->addAttribute('href', $episode->cover->feed_url);
$episode->parental_advisory && $episode->parental_advisory &&
@ -339,18 +304,18 @@ if (! function_exists('get_rss_feed')) {
$episode->parental_advisory === 'explicit' $episode->parental_advisory === 'explicit'
? 'true' ? 'true'
: 'false', : 'false',
$itunesNamespace, RssFeed::ITUNES_NAMESPACE,
); );
$episode->number && $episode->number &&
$item->addChild('episode', (string) $episode->number, $itunesNamespace); $item->addChild('episode', (string) $episode->number, RssFeed::ITUNES_NAMESPACE);
$episode->season_number && $episode->season_number &&
$item->addChild('season', (string) $episode->season_number, $itunesNamespace); $item->addChild('season', (string) $episode->season_number, RssFeed::ITUNES_NAMESPACE);
$item->addChild('episodeType', $episode->type, $itunesNamespace); $item->addChild('episodeType', $episode->type, RssFeed::ITUNES_NAMESPACE);
// If episode is of type trailer, add podcast:trailer tag on channel level // If episode is of type trailer, add podcast:trailer tag on channel level
if ($episode->type === 'trailer') { 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('pubdate', $episode->published_at->format(DATE_RFC2822));
$trailer->addAttribute( $trailer->addAttribute(
'url', 'url',
@ -363,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 // 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('uri', url_to('episode-comments', $podcast->handle, $episode->slug));
$comments->addAttribute('contentType', 'application/podcast-activity+json'); $comments->addAttribute('contentType', 'application/podcast-activity+json');
if ($episode->getPosts()) { if ($episode->getPosts()) {
$socialInteractUri = $episode->getPosts()[0] $socialInteractUri = $episode->getPosts()[0]
->uri; ->uri;
$socialInteractElement = $item->addChild('socialInteract', null, $podcastNamespace); $socialInteractElement = $item->addChild('socialInteract', null, RssFeed::PODCAST_NAMESPACE);
$socialInteractElement->addAttribute('uri', $socialInteractUri); $socialInteractElement->addAttribute('uri', $socialInteractUri);
$socialInteractElement->addAttribute('priority', '1'); $socialInteractElement->addAttribute('priority', '1');
$socialInteractElement->addAttribute('platform', 'castopod'); $socialInteractElement->addAttribute('platform', 'castopod');
$socialInteractElement->addAttribute('protocol', 'activitypub'); $socialInteractElement->addAttribute('protocol', 'activitypub');
$socialInteractElement->addAttribute( $socialInteractElement->addAttribute(
'accountId', 'accountId',
"@{$podcast->actor->username}@{$podcast->actor->domain}" "@{$podcast->actor->username}@{$podcast->actor->domain}",
); );
$socialInteractElement->addAttribute( $socialInteractElement->addAttribute(
'pubDate', 'pubDate',
$episode->getPosts()[0] $episode->getPosts()[0]
->published_at->format(DateTime::ISO8601) ->published_at->format(DateTime::ISO8601),
); );
} }
if ($episode->transcript instanceof Transcript) { 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('url', $episode->transcript->file_url);
$transcriptElement->addAttribute( $transcriptElement->addAttribute(
'type', 'type',
Mimes::guessTypeFromExtension( Mimes::guessTypeFromExtension(
pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION) pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION),
) ?? 'text/html', ) ?? 'text/html',
); );
// Castopod only allows for captions (SubRip files) // Castopod only allows for captions (SubRip files)
@ -409,21 +368,21 @@ if (! function_exists('get_rss_feed')) {
} }
if ($episode->getChapters() instanceof Chapters) { 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('url', $episode->chapters->file_url);
$chaptersElement->addAttribute('type', 'application/json+chapters'); $chaptersElement->addAttribute('type', 'application/json+chapters');
} }
foreach ($episode->soundbites as $soundbite) { foreach ($episode->soundbites as $soundbite) {
// TODO: differentiate video from soundbites? // 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('startTime', (string) $soundbite->start_time);
$soundbiteElement->addAttribute('duration', (string) round($soundbite->duration, 3)); $soundbiteElement->addAttribute('duration', (string) round($soundbite->duration, 3));
} }
foreach ($episode->persons as $person) { foreach ($episode->persons as $person) {
foreach ($person->roles as $role) { 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( $personElement->addAttribute(
'role', 'role',
@ -444,14 +403,10 @@ if (! function_exists('get_rss_feed')) {
} }
if ($episode->is_blocked) { if ($episode->is_blocked) {
$item->addChild('block', 'Yes', $itunesNamespace); $item->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
} }
if ($episode->custom_rss !== null) { $plugins->rssAfterItem($episode, $item);
array_to_rss([
'elements' => $episode->custom_rss,
], $item);
}
} }
return $rss->asXML(); return $rss->asXML();
@ -462,11 +417,9 @@ if (! function_exists('add_category_tag')) {
/** /**
* Adds <itunes:category> and <category> tags to node for a given category * 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, RssFeed::ITUNES_NAMESPACE);
$itunesCategory = $node->addChild('category', null, $itunesNamespace);
$itunesCategory->addAttribute( $itunesCategory->addAttribute(
'text', 'text',
$category->parent instanceof Category $category->parent instanceof Category
@ -475,7 +428,7 @@ if (! function_exists('add_category_tag')) {
); );
if ($category->parent instanceof Category) { 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); $itunesCategoryChild->addAttribute('text', $category->apple_category);
$node->addChild('category', $category->parent->apple_category); $node->addChild('category', $category->parent->apple_category);
} }
@ -483,71 +436,3 @@ if (! function_exists('add_category_tag')) {
$node->addChild('category', $category->apple_category); $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 (strlen($textcontent) > 0) {
$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\Page;
use App\Entities\Podcast; use App\Entities\Podcast;
use App\Entities\Post; use App\Entities\Post;
use Melbahja\Seo\MetaTags; use App\Libraries\HtmlHead;
use Melbahja\Seo\Schema; use Melbahja\Seo\Schema;
use Melbahja\Seo\Schema\Thing; use Melbahja\Seo\Schema\Thing;
use Modules\Fediverse\Entities\PreviewCard; 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 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
if (! function_exists('get_podcast_metatags')) { if (! function_exists('set_podcast_metatags')) {
function get_podcast_metatags(Podcast $podcast, string $page): string function set_podcast_metatags(Podcast $podcast, string $page): void
{ {
$category = ''; $category = '';
if ($podcast->category->parent_id !== null) { if ($podcast->category->parent_id !== null) {
@ -30,7 +30,8 @@ if (! function_exists('get_podcast_metatags')) {
$category .= $podcast->category->apple_category; $category .= $podcast->category->apple_category;
$schema = new Schema( $schema = new Schema(
new Thing('PodcastSeries', [ new Thing(
props: [
'name' => $podcast->title, 'name' => $podcast->title,
'headline' => $podcast->title, 'headline' => $podcast->title,
'url' => current_url(), 'url' => current_url(),
@ -45,13 +46,16 @@ if (! function_exists('get_podcast_metatags')) {
'publisher' => $podcast->publisher, 'publisher' => $podcast->publisher,
'inLanguage' => $podcast->language_code, 'inLanguage' => $podcast->language_code,
'genre' => $category, 'genre' => $category,
]) ],
type: 'PodcastSeries',
),
); );
$metatags = new MetaTags(); /** @var HtmlHead $head */
$head = service('html_head');
$metatags $head
->title($podcast->title . ' (@' . $podcast->handle . ') • ' . lang('Podcast.' . $page)) ->title(sprintf('%s (@%s) • %s', $podcast->title, $podcast->handle, lang('Podcast.' . $page)))
->description(esc($podcast->description)) ->description(esc($podcast->description))
->image((string) $podcast->cover->og_url) ->image((string) $podcast->cover->og_url)
->canonical((string) current_url()) ->canonical((string) current_url())
@ -59,47 +63,51 @@ if (! function_exists('get_podcast_metatags')) {
->og('image:height', (string) config('Images')->podcastCoverSizes['og']['height']) ->og('image:height', (string) config('Images')->podcastCoverSizes['og']['height'])
->og('locale', $podcast->language_code) ->og('locale', $podcast->language_code)
->og('site_name', esc(service('settings')->get('App.siteName'))) ->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [ ->tag('link', null, [
'rel' => 'alternate', 'rel' => 'alternate',
'type' => 'application/activity+json', 'type' => 'application/activity+json',
'href' => url_to('podcast-activity', esc($podcast->handle)), 'href' => url_to('podcast-activity', esc($podcast->handle)),
]); ])->appendRawContent('<link type="application/rss+xml" rel="alternate" title="' . esc(
$podcast->title,
if ($podcast->payment_pointer) { ) . '" href="' . $podcast->feed_url . '" />' . $schema);
$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();
} }
} }
if (! function_exists('get_episode_metatags')) { if (! function_exists('set_episode_metatags')) {
function get_episode_metatags(Episode $episode): string function set_episode_metatags(Episode $episode): void
{ {
$schema = new Schema( $schema = new Schema(
new Thing('PodcastEpisode', [ new Thing(
props: [
'url' => url_to('episode', esc($episode->podcast->handle), $episode->slug), 'url' => url_to('episode', esc($episode->podcast->handle), $episode->slug),
'name' => $episode->title, 'name' => $episode->title,
'image' => $episode->cover->feed_url, 'image' => $episode->cover->feed_url,
'description' => $episode->description, 'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ISO8601), 'datePublished' => $episode->published_at->format(DATE_ATOM),
'timeRequired' => iso8601_duration($episode->audio->duration), 'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration), 'duration' => iso8601_duration($episode->audio->duration),
'associatedMedia' => new Thing('MediaObject', [ 'associatedMedia' => new Thing(
props: [
'contentUrl' => $episode->audio_url, 'contentUrl' => $episode->audio_url,
]), ],
'partOfSeries' => new Thing('PodcastSeries', [ type: 'MediaObject',
),
'partOfSeries' => new Thing(
props: [
'name' => $episode->podcast->title, 'name' => $episode->podcast->title,
'url' => $episode->podcast->link, 'url' => $episode->podcast->link,
]), ],
]) type: 'PodcastSeries',
),
],
type: 'PodcastEpisode',
),
); );
$metatags = new MetaTags(); /** @var HtmlHead $head */
$head = service('html_head');
$metatags $head
->title($episode->title) ->title($episode->title)
->description(esc($episode->description)) ->description(esc($episode->description))
->image((string) $episode->cover->og_url, 'player') ->image((string) $episode->cover->og_url, 'player')
@ -110,68 +118,83 @@ if (! function_exists('get_episode_metatags')) {
->og('locale', $episode->podcast->language_code) ->og('locale', $episode->podcast->language_code)
->og('audio', $episode->audio_opengraph_url) ->og('audio', $episode->audio_opengraph_url)
->og('audio:type', $episode->audio->file_mimetype) ->og('audio:type', $episode->audio->file_mimetype)
->meta('article:published_time', $episode->published_at->format(DATE_ISO8601)) ->meta('article:published_time', $episode->published_at->format(DATE_ATOM))
->meta('article:modified_time', $episode->updated_at->format(DATE_ISO8601)) ->meta('article:modified_time', $episode->updated_at->format(DATE_ATOM))
->twitter('audio:partner', $episode->podcast->publisher ?? '') ->twitter('audio:partner', $episode->podcast->publisher ?? '')
->twitter('audio:artist_name', esc($episode->podcast->owner_name)) ->twitter('audio:artist_name', esc($episode->podcast->owner_name))
->twitter('player', $episode->getEmbedUrl('light')) ->twitter('player', $episode->getEmbedUrl('light'))
->twitter('player:width', (string) config('Embed')->width) ->twitter('player:width', (string) config('Embed')->width)
->twitter('player:height', (string) config('Embed')->height) ->twitter('player:height', (string) config('Embed')->height)
->push('link', [ ->tag('link', null, [
'rel' => 'alternate', 'rel' => 'alternate',
'type' => 'application/activity+json', 'type' => 'application/activity+json',
'href' => url_to('episode', $episode->podcast->handle, $episode->slug), 'href' => $episode->link,
]); ])
->appendRawContent('<link rel="alternate" type="application/json+oembed" href="' . base_url(
if ($episode->podcast->payment_pointer) { route_to('episode-oembed-json', $episode->podcast->handle, $episode->slug),
$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( ) . '" title="' . esc(
$episode->title $episode->title,
) . ' oEmbed json" />' . PHP_EOL . '<link rel="alternate" type="text/xml+oembed" href="' . base_url( ) . ' oEmbed json" />' . '<link rel="alternate" type="text/xml+oembed" href="' . base_url(
route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug) route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc($episode->title) . ' oEmbed xml" />' . PHP_EOL . $schema->__toString(); ) . '" title="' . esc($episode->title) . ' oEmbed xml" />' . $schema);
} }
} }
if (! function_exists('get_post_metatags')) { if (! function_exists('set_post_metatags')) {
function get_post_metatags(Post $post): string function set_post_metatags(Post $post): void
{ {
$socialMediaPosting = new Thing('SocialMediaPosting', [ $socialMediaPosting = new Thing(
props: [
'@id' => url_to('post', esc($post->actor->username), $post->id), '@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('Person', [ 'author' => new Thing(
props: [
'name' => $post->actor->display_name, 'name' => $post->actor->display_name,
'url' => $post->actor->uri, 'url' => $post->actor->uri,
]), ],
type: 'Person',
),
'text' => $post->message, 'text' => $post->message,
]); ],
type: 'SocialMediaPosting',
);
if ($post->episode_id !== null) { if ($post->episode_id !== null) {
$socialMediaPosting->__set('sharedContent', new Thing('Audio', [ $socialMediaPosting->__set('sharedContent', new Thing(
props: [
'headline' => $post->episode->title, 'headline' => $post->episode->title,
'url' => $post->episode->link, 'url' => $post->episode->link,
'author' => new Thing('Person', [ 'author' => new Thing(
props: [
'name' => $post->episode->podcast->owner_name, 'name' => $post->episode->podcast->owner_name,
]), ],
])); type: 'Person',
),
],
type: 'Audio',
));
} elseif ($post->preview_card instanceof PreviewCard) { } elseif ($post->preview_card instanceof PreviewCard) {
$socialMediaPosting->__set('sharedContent', new Thing('WebPage', [ $socialMediaPosting->__set('sharedContent', new Thing(
props: [
'headline' => $post->preview_card->title, 'headline' => $post->preview_card->title,
'url' => $post->preview_card->url, 'url' => $post->preview_card->url,
'author' => new Thing('Person', [ 'author' => new Thing(
props: [
'name' => $post->preview_card->author_name, 'name' => $post->preview_card->author_name,
]), ],
])); type: 'Person',
),
],
type: 'WebPage',
));
} }
$schema = new Schema($socialMediaPosting); $schema = new Schema($socialMediaPosting);
$metatags = new MetaTags(); /** @var HtmlHead $head */
$metatags $head = service('html_head');
$head
->title(lang('Post.title', [ ->title(lang('Post.title', [
'actorDisplayName' => $post->actor->display_name, 'actorDisplayName' => $post->actor->display_name,
])) ]))
@ -179,37 +202,43 @@ if (! function_exists('get_post_metatags')) {
->image($post->actor->avatar_image_url) ->image($post->actor->avatar_image_url)
->canonical((string) current_url()) ->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName'))) ->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [ ->tag('link', null, [
'rel' => 'alternate', 'rel' => 'alternate',
'type' => 'application/activity+json', 'type' => 'application/activity+json',
'href' => url_to('post', esc($post->actor->username), $post->id), 'href' => url_to('post', esc($post->actor->username), $post->id),
]); ])->appendRawContent((string) $schema);
return $metatags->__toString() . PHP_EOL . $schema->__toString();
} }
} }
if (! function_exists('get_episode_comment_metatags')) { if (! function_exists('set_episode_comment_metatags')) {
function get_episode_comment_metatags(EpisodeComment $episodeComment): string function set_episode_comment_metatags(EpisodeComment $episodeComment): void
{ {
$schema = new Schema(new Thing('SocialMediaPosting', [ $schema = new Schema(new Thing(
props: [
'@id' => url_to( '@id' => url_to(
'episode-comment', 'episode-comment',
esc($episodeComment->actor->username), esc($episodeComment->actor->username),
$episodeComment->episode->slug, $episodeComment->episode->slug,
$episodeComment->id $episodeComment->id,
), ),
'datePublished' => $episodeComment->created_at->format(DATE_ISO8601), 'datePublished' => $episodeComment->created_at->format(DATE_ATOM),
'author' => new Thing('Person', [ 'author' => new Thing(
props: [
'name' => $episodeComment->actor->display_name, 'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri, 'url' => $episodeComment->actor->uri,
]), ],
type: 'Person',
),
'text' => $episodeComment->message, 'text' => $episodeComment->message,
'upvoteCount' => $episodeComment->likes_count, 'upvoteCount' => $episodeComment->likes_count,
])); ],
type: 'SocialMediaPosting',
));
$metatags = new MetaTags(); /** @var HtmlHead $head */
$metatags $head = service('html_head');
$head
->title(lang('Comment.title', [ ->title(lang('Comment.title', [
'actorDisplayName' => $episodeComment->actor->display_name, 'actorDisplayName' => $episodeComment->actor->display_name,
'episodeTitle' => $episodeComment->episode->title, 'episodeTitle' => $episodeComment->episode->title,
@ -218,26 +247,25 @@ if (! function_exists('get_episode_comment_metatags')) {
->image($episodeComment->actor->avatar_image_url) ->image($episodeComment->actor->avatar_image_url)
->canonical((string) current_url()) ->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName'))) ->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [ ->tag('link', null, [
'rel' => 'alternate', 'rel' => 'alternate',
'type' => 'application/activity+json', 'type' => 'application/activity+json',
'href' => url_to( 'href' => url_to(
'episode-comment', 'episode-comment',
$episodeComment->actor->username, $episodeComment->actor->username,
$episodeComment->episode->slug, $episodeComment->episode->slug,
$episodeComment->id $episodeComment->id,
), ),
]); ])->appendRawContent((string) $schema);
return $metatags->__toString() . PHP_EOL . $schema->__toString();
} }
} }
if (! function_exists('get_follow_metatags')) { if (! function_exists('set_follow_metatags')) {
function get_follow_metatags(Actor $actor): string function set_follow_metatags(Actor $actor): void
{ {
$metatags = new MetaTags(); /** @var HtmlHead $head */
$metatags $head = service('html_head');
$head
->title(lang('Podcast.followTitle', [ ->title(lang('Podcast.followTitle', [
'actorDisplayName' => $actor->display_name, 'actorDisplayName' => $actor->display_name,
])) ]))
@ -245,16 +273,15 @@ if (! function_exists('get_follow_metatags')) {
->image($actor->avatar_image_url) ->image($actor->avatar_image_url)
->canonical((string) current_url()) ->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName'))); ->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
} }
} }
if (! function_exists('get_remote_actions_metatags')) { if (! function_exists('set_remote_actions_metatags')) {
function get_remote_actions_metatags(Post $post, string $action): string function set_remote_actions_metatags(Post $post, string $action): void
{ {
$metatags = new MetaTags(); /** @var HtmlHead $head */
$metatags $head = service('html_head');
$head
->title(lang('Fediverse.' . $action . '.title', [ ->title(lang('Fediverse.' . $action . '.title', [
'actorDisplayName' => $post->actor->display_name, 'actorDisplayName' => $post->actor->display_name,
],)) ],))
@ -262,42 +289,40 @@ if (! function_exists('get_remote_actions_metatags')) {
->image($post->actor->avatar_image_url) ->image($post->actor->avatar_image_url)
->canonical((string) current_url()) ->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName'))); ->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
} }
} }
if (! function_exists('get_home_metatags')) { if (! function_exists('set_home_metatags')) {
function get_home_metatags(): string function set_home_metatags(): void
{ {
$metatags = new MetaTags(); /** @var HtmlHead $head */
$metatags $head = service('html_head');
$head
->title(service('settings')->get('App.siteName')) ->title(service('settings')->get('App.siteName'))
->description(esc(service('settings')->get('App.siteDescription'))) ->description(esc(service('settings')->get('App.siteDescription')))
->image(get_site_icon_url('512')) ->image(get_site_icon_url('512'))
->canonical((string) current_url()) ->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName'))); ->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
} }
} }
if (! function_exists('get_page_metatags')) { if (! function_exists('set_page_metatags')) {
function get_page_metatags(Page $page): string function set_page_metatags(Page $page): void
{ {
$metatags = new MetaTags(); /** @var HtmlHead $head */
$metatags $head = service('html_head');
$head
->title( ->title(
$page->title . service('settings')->get('App.siteTitleSeparator') . service( $page->title . service('settings')->get('App.siteTitleSeparator') . service(
'settings' 'settings',
)->get('App.siteName') )->get('App.siteName'),
) )
->description(esc(service('settings')->get('App.siteDescription'))) ->description(esc(service('settings')->get('App.siteDescription')))
->image(get_site_icon_url('512')) ->image(get_site_icon_url('512'))
->canonical((string) current_url()) ->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName'))); ->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
} }
} }

View file

@ -24,7 +24,7 @@ return [
'comments' => 'Kommentare', 'comments' => 'Kommentare',
'activity' => 'Aktivitäten', 'activity' => 'Aktivitäten',
'chapters' => 'Kapitel', 'chapters' => 'Kapitel',
'transcript' => 'Transcript', 'transcript' => 'Protokoll',
'description' => 'Beschreibung der Episode', 'description' => 'Beschreibung der Episode',
'number_of_comments' => '{numberOfComments, plural, 'number_of_comments' => '{numberOfComments, plural,
one {# Kommentar} one {# Kommentar}
@ -45,6 +45,6 @@ return [
'publish_edit' => 'Veröffentlichung bearbeiten', 'publish_edit' => 'Veröffentlichung bearbeiten',
], ],
'no_chapters' => 'Für diese Episode sind keine Kapitel verfügbar.', 'no_chapters' => 'Für diese Episode sind keine Kapitel verfügbar.',
'download_transcript' => 'Download transcript ({extension})', 'download_transcript' => 'Protokoll herunterladen ({extension})',
'no_transcript' => 'No transcript available for this episode.', 'no_transcript' => 'Für diese Episode ist kein Protokoll verfügbar.',
]; ];

View file

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

View file

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

View file

@ -37,4 +37,7 @@ return [
'block_actor' => 'Block user @{actorUsername}', 'block_actor' => 'Block user @{actorUsername}',
'block_domain' => 'Block domain @{actorDomain}', 'block_domain' => 'Block domain @{actorDomain}',
'delete' => 'Delete post', 'delete' => 'Delete post',
'is_public' => 'Post is public',
'is_private' => 'Post is private',
'cannot_reblog' => 'This private post cannot be shared.',
]; ];

View file

@ -23,7 +23,7 @@ return [
'comments' => 'コメント', 'comments' => 'コメント',
'activity' => 'アクティビティ', 'activity' => 'アクティビティ',
'chapters' => '章', 'chapters' => '章',
'transcript' => 'Transcript', 'transcript' => '文字起こし',
'description' => 'エピソードの詳細', 'description' => 'エピソードの詳細',
'number_of_comments' => '{numberOfComments, plural, 'number_of_comments' => '{numberOfComments, plural,
one {# comment} one {# comment}
@ -43,7 +43,7 @@ return [
'publish' => '公開する', 'publish' => '公開する',
'publish_edit' => '出版物を編集', 'publish_edit' => '出版物を編集',
], ],
'no_chapters' => 'No chapters are available for this episode.', 'no_chapters' => 'このエピソードにはチャプターがありません。',
'download_transcript' => 'Download transcript ({extension})', 'download_transcript' => '文字起こしをダウンロード ({extension})',
'no_transcript' => 'No transcript available for this episode.', 'no_transcript' => 'このエピソードには文字起こしがありません。',
]; ];

View file

@ -10,28 +10,28 @@ declare(strict_types=1);
return [ return [
'your_handle' => 'あなたのユーザー ID', 'your_handle' => 'あなたのユーザー ID',
'your_handle_hint' => 'Enter the @username@domain you want to act from.', 'your_handle_hint' => 'フォームに「@username@domain」の形式で入力してください',
'follow' => [ 'follow' => [
'label' => 'フォロー', 'label' => 'フォロー',
'title' => '{actorDisplayName} をフォロー', 'title' => '{actorDisplayName} をフォロー',
'subtitle' => 'You are going to follow:', 'subtitle' => 'フォロー中:',
'accountNotFound' => 'アカウントが見つかりませんでした', 'accountNotFound' => 'アカウントが見つかりませんでした',
'remoteFollowNotAllowed' => 'このアカウントサーバーはリモートフォローを許可しておりません', 'remoteFollowNotAllowed' => 'このアカウントサーバーはリモートフォローを許可しておりません',
'submit' => 'フォローする', 'submit' => 'フォローする',
], ],
'favourite' => [ 'favourite' => [
'title' => "お気に入りの {actorDisplayName}の投稿", 'title' => "お気に入りの {actorDisplayName}の投稿",
'subtitle' => 'You are going to favourite:', 'subtitle' => 'お気に入りに登録中:',
'submit' => 'お気に入り登録する', 'submit' => 'お気に入り登録する',
], ],
'reblog' => [ 'reblog' => [
'title' => "Share {actorDisplayName}'s post", 'title' => "{actorDisplayName} の投稿を共有する",
'subtitle' => 'You are going to share:', 'subtitle' => '共有中:',
'submit' => '共有する', 'submit' => '共有する',
], ],
'reply' => [ 'reply' => [
'title' => "Reply to {actorDisplayName}'s post", 'title' => "{actorDisplayName} の投稿に返信する",
'subtitle' => 'You are going to reply to:', 'subtitle' => '返信中:',
'submit' => '返信する', 'submit' => '返信する',
], ],
]; ];

View file

@ -38,16 +38,16 @@ return [
one {# episode} one {# episode}
other {# episodes} other {# episodes}
}', }',
'first_published_at' => 'First episode published on {0, date, medium}', 'first_published_at' => '初回は{0, date, medium} に投稿されました。',
], ],
'sponsor' => 'Sponsor', 'sponsor' => 'スポンサー',
'funding_links' => 'Funding links for {podcastTitle}', 'funding_links' => '{podcastTitle} のリンクを探す',
'find_on' => 'Find {podcastTitle} on', 'find_on' => '{podcastTitle} を検索',
'listen_on' => 'Listen on', 'listen_on' => '視聴中',
'persons' => '{personsCount, plural, 'persons' => '{personsCount, plural,
one {# person} one {# person}
other {# persons} other {# persons}
}', }',
'persons_list' => 'Persons', 'persons_list' => '人数',
'castopod_website' => 'Castopod (website)', 'castopod_website' => 'Castopod (公式ページ)',
]; ];

View file

@ -9,15 +9,15 @@ declare(strict_types=1);
*/ */
return [ return [
'title' => "{actorDisplayName}'s post", 'title' => "{actorDisplayName} の投稿",
'back_to_actor_posts' => 'Back to {actor} posts', 'back_to_actor_posts' => '{actor} の投稿一覧に戻る',
'actor_shared' => '{actor} shared', 'actor_shared' => '{actor} が共有しました',
'reply_to' => 'Reply to @{actorUsername}', 'reply_to' => '@{actorUsername} に返信する',
'form' => [ 'form' => [
'message_placeholder' => 'Write a message…', 'message_placeholder' => 'ここにコメントを入力..',
'episode_message_placeholder' => 'Write a message for the episode…', 'episode_message_placeholder' => 'エピソードへのコメントを入力...',
'episode_url_placeholder' => 'Episode URL', 'episode_url_placeholder' => 'エピソードのURL',
'reply_to_placeholder' => 'Reply to @{actorUsername}', 'reply_to_placeholder' => '@{actorUsername} に返信する',
'submit' => '送信', 'submit' => '送信',
'submit_reply' => '返信する', 'submit_reply' => '返信する',
], ],
@ -33,8 +33,8 @@ return [
one {# reply} one {# reply}
other {# replies} other {# replies}
}', }',
'expand' => 'Expand post', 'expand' => '投稿を開く',
'block_actor' => 'Block user @{actorUsername}', 'block_actor' => '@{actorUsername} をブロック',
'block_domain' => 'Block domain @{actorDomain}', 'block_domain' => '@{actorDomain} の投稿をブロックする',
'delete' => '投稿を削除', 'delete' => '投稿を削除',
]; ];

View file

@ -24,7 +24,7 @@ return [
'comments' => 'Reacties', 'comments' => 'Reacties',
'activity' => 'Activiteiten', 'activity' => 'Activiteiten',
'chapters' => 'Hoofdstukken', 'chapters' => 'Hoofdstukken',
'transcript' => 'Transcript', 'transcript' => 'Transcriptie',
'description' => 'Omschrijving aflevering', 'description' => 'Omschrijving aflevering',
'number_of_comments' => '{numberOfComments, plural, 'number_of_comments' => '{numberOfComments, plural,
one {# reactie} one {# reactie}
@ -45,6 +45,6 @@ return [
'publish_edit' => 'Publicatie bewerken', 'publish_edit' => 'Publicatie bewerken',
], ],
'no_chapters' => 'Voor deze aflevering zijn geen hoofdstukken beschikbaar.', 'no_chapters' => 'Voor deze aflevering zijn geen hoofdstukken beschikbaar.',
'download_transcript' => 'Download transcript ({extension})', 'download_transcript' => 'Transcriptie downloaden ({extension})',
'no_transcript' => 'No transcript available for this episode.', 'no_transcript' => 'Geen transcript beschikbaar voor deze aflevering.',
]; ];

View file

@ -24,7 +24,7 @@ return [
'comments' => 'Kommentarar', 'comments' => 'Kommentarar',
'activity' => 'Aktivitet', 'activity' => 'Aktivitet',
'chapters' => 'Kapittel', 'chapters' => 'Kapittel',
'transcript' => 'Transcript', 'transcript' => 'Avskrift',
'description' => 'Skildring av episoden', 'description' => 'Skildring av episoden',
'number_of_comments' => '{numberOfComments, plural, 'number_of_comments' => '{numberOfComments, plural,
one {# kommentar} one {# kommentar}
@ -45,6 +45,6 @@ return [
'publish_edit' => 'Rediger publiseringa', 'publish_edit' => 'Rediger publiseringa',
], ],
'no_chapters' => 'Det finst ingen kapittel for denne episoden.', 'no_chapters' => 'Det finst ingen kapittel for denne episoden.',
'download_transcript' => 'Download transcript ({extension})', 'download_transcript' => 'Last ned underteksten ({extension})',
'no_transcript' => 'No transcript available for this episode.', 'no_transcript' => 'Det finst inga teksting for denne episoden.',
]; ];

View file

@ -31,5 +31,5 @@ return [
'view_replies' => 'Zobacz odpowiedzi ({numberOfReplies})', 'view_replies' => 'Zobacz odpowiedzi ({numberOfReplies})',
'block_actor' => 'Zablokuj użytkownika @{actorUsername}', 'block_actor' => 'Zablokuj użytkownika @{actorUsername}',
'block_domain' => 'Zablokuj domenę @{actorDomain}', 'block_domain' => 'Zablokuj domenę @{actorDomain}',
'delete' => 'usuń komentarz', 'delete' => 'Usuń komentarz',
]; ];

View file

@ -14,15 +14,15 @@ return [
'cancel' => 'Anuluj', 'cancel' => 'Anuluj',
'optional' => 'Opcjonalnie', 'optional' => 'Opcjonalnie',
'close' => 'Zamknij', 'close' => 'Zamknij',
'home' => 'Początek', 'home' => 'Strona główna',
'explicit' => 'Zawiera treści dla dorosłych', 'explicit' => 'Wulgarne',
'powered_by' => 'Wspierane przez {castopod}', 'powered_by' => 'Wspierane przez {castopod}',
'go_back' => 'Wróć', 'go_back' => 'Wróć',
'play_episode_button' => [ 'play_episode_button' => [
'play' => 'Odtwórz', 'play' => 'Odtwórz',
'playing' => 'Odtwarzanie', 'playing' => 'Odtwarzanie',
], ],
'read_more' => 'czytaj więcej', 'read_more' => 'Czytaj więcej',
'read_less' => 'Czytaj mniej', 'read_less' => 'Czytaj mniej',
'see_more' => 'Zobacz więcej', 'see_more' => 'Zobacz więcej',
'see_less' => 'Zobacz mniej', 'see_less' => 'Zobacz mniej',

View file

@ -14,7 +14,7 @@ return [
'number' => 'Odcinek {episodeNumber}', 'number' => 'Odcinek {episodeNumber}',
'number_abbr' => 'Odc. {episodeNumber}', 'number_abbr' => 'Odc. {episodeNumber}',
'season_episode' => 'Sezon {seasonNumber} odcinek {episodeNumber}', 'season_episode' => 'Sezon {seasonNumber} odcinek {episodeNumber}',
'season_episode_abbr' => 'S{seasonNumber}:O{episodeNumber}', 'season_episode_abbr' => 'S{seasonNumber}:E{episodeNumber}',
'persons' => '{personsCount, plural, 'persons' => '{personsCount, plural,
one {# osoba} one {# osoba}
few {# osoby} few {# osoby}
@ -24,8 +24,8 @@ return [
'back_to_episodes' => 'Wróć do odcinków {podcast}', 'back_to_episodes' => 'Wróć do odcinków {podcast}',
'comments' => 'Komentarze', 'comments' => 'Komentarze',
'activity' => 'Aktywność', 'activity' => 'Aktywność',
'chapters' => 'Chapters', 'chapters' => 'Rozdziały',
'transcript' => 'Transcript', 'transcript' => 'Transkrypcja',
'description' => 'Opis odcinka', 'description' => 'Opis odcinka',
'number_of_comments' => '{numberOfComments, plural, 'number_of_comments' => '{numberOfComments, plural,
one {# komentarz} one {# komentarz}
@ -33,7 +33,7 @@ return [
other {# komentarzy} other {# komentarzy}
}', }',
'all_podcast_episodes' => 'Wszystkie odcinki podcastu', 'all_podcast_episodes' => 'Wszystkie odcinki podcastu',
'back_to_podcast' => 'Wróć do podkastu', 'back_to_podcast' => 'Wróć do podcastu',
'preview' => [ 'preview' => [
'title' => 'Podgląd', 'title' => 'Podgląd',
'not_published' => 'Nieopublikowany', 'not_published' => 'Nieopublikowany',
@ -46,7 +46,7 @@ return [
'publish' => 'Opublikuj', 'publish' => 'Opublikuj',
'publish_edit' => 'Edytuj publikację', 'publish_edit' => 'Edytuj publikację',
], ],
'no_chapters' => 'No chapters are available for this episode.', 'no_chapters' => 'Brak dostępnych rozdziałów dla tego odcinka.',
'download_transcript' => 'Download transcript ({extension})', 'download_transcript' => 'Pobierz transkrypcję ({extension})',
'no_transcript' => 'No transcript available for this episode.', 'no_transcript' => 'Brak transkrypcji dla tego odcinka.',
]; ];

View file

@ -10,28 +10,28 @@ declare(strict_types=1);
return [ return [
'your_handle' => 'Twój uchwyt', 'your_handle' => 'Twój uchwyt',
'your_handle_hint' => 'Wpisz @nazwęużytkownika@domenę, z których chcesz działać.', 'your_handle_hint' => 'Wpisz @nazważytkownika@domena, z której chcesz działać.',
'follow' => [ 'follow' => [
'label' => 'Obserwuj', 'label' => 'Obserwuj',
'title' => 'Obserwuj {actorDisplayName}', 'title' => 'Obserwuj {actorDisplayName}',
'subtitle' => 'Zamierzasz obserwować:', 'subtitle' => 'Zamierzasz obserwować:',
'accountNotFound' => 'Nie można znaleźć konta.', 'accountNotFound' => 'Nie można znaleźć konta.',
'remoteFollowNotAllowed' => 'Wygląda na to, że serwer kont nie pozwala na śledzenie zdalnie…', 'remoteFollowNotAllowed' => 'Wygląda na to, że serwer kont nie pozwala na zdalne śledzenie…',
'submit' => 'Przejdź do obserwowania', 'submit' => 'Przejdź do obserwowania',
], ],
'favourite' => [ 'favourite' => [
'title' => "Dodaj do ulubionych wpis {actorDisplayName}", 'title' => "Dodaj do ulubionych wpis {actorDisplayName}",
'subtitle' => 'Zamierzasz dodać do ulubionych:', 'subtitle' => 'Zamierzasz dodać do ulubionych:',
'submit' => 'Przejdź do dodania do ulubionych', 'submit' => 'Dodaj do ulubionych',
], ],
'reblog' => [ 'reblog' => [
'title' => "Udostępnij wpis {actorDisplayName}", 'title' => "Udostępnij wpis {actorDisplayName}",
'subtitle' => 'Zamierzasz udostępnić:', 'subtitle' => 'Zamierzasz udostępnić:',
'submit' => 'Przejdź do udostępnienia', 'submit' => 'Udostępnij',
], ],
'reply' => [ 'reply' => [
'title' => "Odpowiedź do wpisu {actorDisplayName}", 'title' => "Odpowiedz do wpisu {actorDisplayName}",
'subtitle' => 'Zamierzasz odpisać na:', 'subtitle' => 'Zamierzasz odpisać na:',
'submit' => 'Przejdź do odpowiedzi', 'submit' => 'Odpowiedz',
], ],
]; ];

View file

@ -9,9 +9,9 @@ declare(strict_types=1);
*/ */
return [ return [
'back_to_home' => 'Wróć do początku', 'back_to_home' => 'Wróć do strony głównej',
'map' => [ 'map' => [
'title' => 'Mapa', 'title' => 'Mapa',
'description' => 'Odkryj odcinki podcastów w witrynie {siteName} umieszczone na mapie! Podróżuj po mapie i słuchaj odcinków, które opowiadają o konkretnych lokalizacjach.', 'description' => 'Odkryj odcinki podcastów w witrynie {siteName}, które są umieszczone na mapie! Podróżuj po mapie i słuchaj odcinków, które opowiadają o konkretnych lokalizacjach.',
], ],
]; ];

View file

@ -16,7 +16,7 @@ return [
'Sezon {seasonNumber} odcinki ({episodeCount})', 'Sezon {seasonNumber} odcinki ({episodeCount})',
'no_episode' => 'Nie znaleziono odcinków!', 'no_episode' => 'Nie znaleziono odcinków!',
'follow' => 'Obserwuj', 'follow' => 'Obserwuj',
'followTitle' => 'Obserwuj {actorDisplayName} na fediverse!', 'followTitle' => 'Obserwuj {actorDisplayName} na fediwersum!',
'followers' => '{numberOfFollowers, plural, 'followers' => '{numberOfFollowers, plural,
one {# polubienie} one {# polubienie}
few {# polubienia} few {# polubienia}
@ -27,7 +27,7 @@ return [
few {# osoby} few {# osoby}
other {# osób} other {# osób}
}', }',
'links' => 'Links', 'links' => 'Linki',
'activity' => 'Wpisy', 'activity' => 'Wpisy',
'episodes' => 'Odcinki', 'episodes' => 'Odcinki',
'episodes_title' => 'Odcinki {podcastTitle}', 'episodes_title' => 'Odcinki {podcastTitle}',
@ -56,5 +56,5 @@ return [
other {# osób} other {# osób}
}', }',
'persons_list' => 'Osoby', 'persons_list' => 'Osoby',
'castopod_website' => 'Castopod (website)', 'castopod_website' => 'Castopod (strona)',
]; ];

View file

@ -32,12 +32,18 @@ class Breadcrumb
$uri = ''; $uri = '';
foreach (current_url(true)->getSegments() as $segment) { foreach (current_url(true)->getSegments() as $segment) {
$uri .= '/' . $segment; $uri .= '/' . $segment;
$this->links[] = [ $link = [
'text' => is_numeric($segment) 'text' => is_numeric($segment)
? $segment ? $segment
: lang('Breadcrumb.' . $segment), : lang('Breadcrumb.' . $segment),
'href' => base_url($uri), '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 * 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 public function replaceParams(array $newParams): void
{ {
foreach ($this->links as $key => $link) { foreach ($newParams as $key => $newValue) {
if (is_numeric($link['text'])) { if (array_key_exists($key, $this->links)) {
$this->links[$key]['text'] = $newParams[0]; $this->links[$key]['text'] = $newValue;
array_shift($newParams);
} }
} }
} }
@ -67,7 +72,7 @@ class Breadcrumb
/** /**
* Renders the breadcrumb object as an accessible html breadcrumb nav * 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 = ''; $listItems = '';
$keys = array_keys($this->links); $keys = array_keys($this->links);

View file

@ -39,7 +39,7 @@ class CommentObject extends ObjectType
'episode-comment-replies', 'episode-comment-replies',
esc($comment->actor->username), esc($comment->actor->username),
$comment->episode->slug, $comment->episode->slug,
$comment->id $comment->id,
); );
$this->cc = [$comment->actor->followers_url]; $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,10 +15,11 @@ declare(strict_types=1);
namespace App\Libraries; namespace App\Libraries;
use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\Router\Exceptions\RedirectException; use CodeIgniter\HTTP\Exceptions\RedirectException;
use CodeIgniter\Router\Exceptions\RouterException; use CodeIgniter\Router\Exceptions\RouterException;
use CodeIgniter\Router\Router as CodeIgniterRouter; use CodeIgniter\Router\Router as CodeIgniterRouter;
use Config\Services; use Config\Routing;
use Override;
class Router extends CodeIgniterRouter class Router extends CodeIgniterRouter
{ {
@ -30,6 +31,7 @@ class Router extends CodeIgniterRouter
* *
* @return boolean Whether the route was matched or not. * @return boolean Whether the route was matched or not.
*/ */
#[Override]
protected function checkRoutes(string $uri): bool protected function checkRoutes(string $uri): bool
{ {
$routes = $this->collection->getRoutes($this->collection->getHTTPVerb()); $routes = $this->collection->getRoutes($this->collection->getHTTPVerb());
@ -43,7 +45,7 @@ class Router extends CodeIgniterRouter
// Loop through the route array looking for wildcards // Loop through the route array looking for wildcards
foreach ($routes as $routeKey => $handler) { foreach ($routes as $routeKey => $handler) {
$routeKey = $routeKey === '/' ? $routeKey : ltrim($routeKey, '/ '); $routeKey = $routeKey === '/' ? $routeKey : ltrim((string) $routeKey, '/ ');
$matchedKey = $routeKey; $matchedKey = $routeKey;
@ -67,7 +69,7 @@ class Router extends CodeIgniterRouter
throw new RedirectException( throw new RedirectException(
preg_replace('#^' . $routeKey . '$#u', (string) $redirectTo, $uri), preg_replace('#^' . $routeKey . '$#u', (string) $redirectTo, $uri),
$this->collection->getRedirectCode($routeKey) $this->collection->getRedirectCode($routeKey),
); );
} }
@ -77,7 +79,7 @@ class Router extends CodeIgniterRouter
preg_match( preg_match(
'#^' . str_replace('{locale}', '(?<locale>[^/]+)', $matchedKey) . '$#u', '#^' . str_replace('{locale}', '(?<locale>[^/]+)', $matchedKey) . '$#u',
$uri, $uri,
$matched $matched,
); );
if ($this->collection->shouldUseSupportedLocalesOnly() if ($this->collection->shouldUseSupportedLocalesOnly()
@ -115,8 +117,8 @@ class Router extends CodeIgniterRouter
array_key_exists('alternate-content', $this->matchedRouteOptions) && array_key_exists('alternate-content', $this->matchedRouteOptions) &&
is_array($this->matchedRouteOptions['alternate-content']) is_array($this->matchedRouteOptions['alternate-content'])
) { ) {
$request = Services::request(); $request = service('request');
$negotiate = Services::negotiator(); $negotiate = service('negotiator');
// Accept header is mandatory // Accept header is mandatory
if ($request->header('Accept') === null) { if ($request->header('Accept') === null) {
@ -180,24 +182,50 @@ class Router extends CodeIgniterRouter
return true; return true;
} }
[$controller] = explode('::', (string) $handler); if (str_contains((string) $handler, '::')) {
[$controller, $methodAndParams] = explode('::', (string) $handler);
} else {
$controller = $handler;
$methodAndParams = '';
}
// Checks `/` in controller name // Checks `/` in controller name
if (str_contains($controller, '/')) { if (str_contains((string) $controller, '/')) {
throw RouterException::forInvalidControllerName($handler); throw RouterException::forInvalidControllerName($handler);
} }
if (str_contains((string) $handler, '$') && str_contains($routeKey, '(')) { if (str_contains((string) $handler, '$') && str_contains($routeKey, '(')) {
// Checks dynamic controller // Checks dynamic controller
if (str_contains($controller, '$')) { if (str_contains((string) $controller, '$')) {
throw RouterException::forDynamicController($handler); throw RouterException::forDynamicController($handler);
} }
if (config(Routing::class)->multipleSegmentsOneParam === false) {
// Using back-references // Using back-references
$handler = preg_replace('#^' . $routeKey . '$#u', (string) $handler, $uri); $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];
} }
$this->setRequest(explode('/', (string) $handler)); $segments = [];
foreach ($handlerSegments as $segment) {
$segments[] = $this->replaceBackReferences($segment, $matches);
}
}
} else {
$segments = explode('/', (string) $handler);
}
$this->setRequest($segments);
$this->setMatchedRoute($matchedKey, $handler); $this->setMatchedRoute($matchedKey, $handler);

View file

@ -11,10 +11,34 @@ declare(strict_types=1);
namespace App\Libraries; namespace App\Libraries;
use DOMDocument; use DOMDocument;
use Override;
use SimpleXMLElement; 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 * 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. * @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 public function addChild($name, $value = null, $namespace = null, $escape = true): static
{ {
$newChild = parent::addChild($name, null, $namespace); $newChild = parent::addChild($name, null, $namespace);
@ -57,12 +82,41 @@ class SimpleRSSElement extends SimpleXMLElement
return $newChild; return $newChild;
} }
if (is_array($value)) {
return $newChild;
}
$node->appendChild($no->createTextNode($value)); $node->appendChild($no->createTextNode($value));
return $newChild; 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; namespace ViewComponents;
class Component implements ComponentInterface use Override;
{
protected string $slot = '';
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> * @var array<string, string>
*/ */
protected array $attributes = [ protected array $attributes = [];
'class' => '',
];
/** /**
* @param array<string, string> $attributes * @param array<string, string> $attributes
*/ */
public function __construct(array $attributes) public function __construct(array $attributes)
{ {
helper('viewcomponents');
// overwrite default attributes if set // overwrite default attributes if set
$this->attributes = [...$this->attributes, ...$attributes]; $this->attributes = [...$this->attributes, ...$attributes];
@ -42,11 +48,42 @@ class Component implements ComponentInterface
if (is_callable([$this, $method])) { if (is_callable([$this, $method])) {
$this->{$method}($value); $this->{$method}($value);
} else { } 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; $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 public function render(): string
{ {
return static::class . ': RENDER METHOD NOT IMPLEMENTED'; return static::class . ': RENDER METHOD NOT IMPLEMENTED';

View file

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

View file

@ -22,6 +22,7 @@ class Services extends BaseService
public static function components(bool $getShared = true): ComponentRenderer public static function components(bool $getShared = true): ComponentRenderer
{ {
if ($getShared) { if ($getShared) {
/** @phpstan-ignore return.type */
return self::getSharedInstance('components'); return self::getSharedInstance('components');
} }

View file

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

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Vite\Config;
use CodeIgniter\Config\BaseService;
use Vite\Vite;
/**
* Services Configuration file.
*
* Services are simply other classes/libraries that the system uses to do its job. This is used by CodeIgniter to allow
* the core of the framework to be swapped out easily without affecting the usage within the rest of your application.
*
* This file holds any application-specific services, or service overrides that you might need. An example has been
* included with the general method format you should use for your service methods. For more examples, see the core
* Services file at system/Config/Services.php.
*/
class Services extends BaseService
{
public static function vite(bool $getShared = true): Vite
{
if ($getShared) {
return self::getSharedInstance('vite');
}
return new Vite();
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Vite\Config;
use CodeIgniter\Config\BaseConfig;
class Vite extends BaseConfig
{
public string $environment = 'production';
public string $baseUrl = 'http://localhost:5173/';
public string $assetsRoot = 'assets';
public string $manifestFile = '.vite/manifest.json';
public string $manifestCSSFile = 'manifest-css.json';
}

View file

@ -1,111 +0,0 @@
<?php
declare(strict_types=1);
namespace Vite;
use ErrorException;
class Vite
{
/**
* @var array<string, mixed>|null
*/
protected ?array $manifestData = null;
/**
* @var array<string, mixed>|null
*/
protected ?array $manifestCSSData = null;
public function asset(string $path, string $type): string
{
if (config('Vite')->environment !== 'production') {
return $this->loadDev($path, $type);
}
return $this->loadProd($path, $type);
}
private function loadDev(string $path, string $type): string
{
return $this->getHtmlTag(config('Vite') ->baseUrl . config('Vite')->assetsRoot . "/{$path}", $type);
}
private function loadProd(string $path, string $type): string
{
if ($this->manifestData === null) {
$cacheName = 'vite-manifest';
if (! ($cachedManifest = cache($cacheName))) {
$manifestPath = config('Vite')
->assetsRoot . '/' . config('Vite')
->manifestFile;
try {
if (($manifestContents = file_get_contents($manifestPath)) !== false) {
$cachedManifest = json_decode($manifestContents, true);
cache()
->save($cacheName, $cachedManifest, DECADE);
}
} catch (ErrorException) {
// ERROR when retrieving the manifest file
die("Could not load manifest: <strong>{$manifestPath}</strong> file not found!");
}
}
$this->manifestData = $cachedManifest;
}
$html = '';
if (array_key_exists($path, $this->manifestData)) {
$manifestElement = $this->manifestData[$path];
// import css dependencies if any
if (array_key_exists('css', $manifestElement)) {
foreach ($manifestElement['css'] as $cssFile) {
$html .= $this->getHtmlTag('/' . config('Vite')->assetsRoot . '/' . $cssFile, 'css');
}
}
// import dependencies first for faster js loading
if (array_key_exists('imports', $manifestElement)) {
foreach ($manifestElement['imports'] as $importPath) {
if (array_key_exists($importPath, $this->manifestData)) {
// import css dependencies if any
if (array_key_exists('css', $this->manifestData[$importPath])) {
foreach ($this->manifestData[$importPath]['css'] as $cssFile) {
$html .= $this->getHtmlTag(
'/' . config('Vite')->assetsRoot . '/' . $cssFile,
'css'
);
}
}
$html .= $this->getHtmlTag(
'/' . config('Vite')->assetsRoot . '/' . $this->manifestData[$importPath]['file'],
'js'
);
}
}
}
$html .= $this->getHtmlTag('/' . config('Vite')->assetsRoot . '/' . $manifestElement['file'], $type);
}
return $html;
}
private function getHtmlTag(string $assetUrl, string $type): string
{
return match ($type) {
'css' => <<<HTML
<link rel="stylesheet" href="{$assetUrl}"/>
HTML
,
'js' => <<<HTML
<script type="module" src="{$assetUrl}"></script>
HTML
,
default => '',
};
}
}

View file

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

View file

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

View file

@ -67,8 +67,8 @@ class ClipModel extends Model
public function __construct( public function __construct(
protected string $type = 'audio', protected string $type = 'audio',
ConnectionInterface &$db = null, ?ConnectionInterface &$db = null,
ValidationInterface $validation = null ?ValidationInterface $validation = null,
) { ) {
switch ($type) { switch ($type) {
case 'audio': case 'audio':
@ -122,7 +122,6 @@ class ClipModel extends Model
$found[$key] = new VideoClip($videoClip->toArray()); $found[$key] = new VideoClip($videoClip->toArray());
} }
// @phpstan-ignore-next-line
return $found; return $found;
} }
@ -162,15 +161,11 @@ class ClipModel extends Model
return (int) $result[0]['id']; return (int) $result[0]['id'];
} }
public function deleteVideoClip(int $podcastId, int $episodeId, int $clipId): BaseResult | bool public function deleteVideoClip(int $clipId): BaseResult | bool
{ {
$this->clearVideoClipCache($clipId); $this->clearVideoClipCache($clipId);
return $this->delete([ return $this->delete($clipId);
'podcast_id' => $podcastId,
'episode_id' => $episodeId,
'id' => $clipId,
]);
} }
public function getClipCount(int $podcastId, int $episodeId): int public function getClipCount(int $podcastId, int $episodeId): int
@ -240,11 +235,7 @@ class ClipModel extends Model
{ {
$this->clearSoundbiteCache($podcastId, $episodeId, $clipId); $this->clearSoundbiteCache($podcastId, $episodeId, $clipId);
return $this->delete([ return $this->delete($clipId);
'podcast_id' => $podcastId,
'episode_id' => $episodeId,
'id' => $clipId,
]);
} }
public function clearSoundbiteCache(int $podcastId, int $episodeId, int $clipId): void public function clearSoundbiteCache(int $podcastId, int $episodeId, int $clipId): void

View file

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

View file

@ -25,7 +25,7 @@ use Modules\Fediverse\Objects\TombstoneObject;
class EpisodeCommentModel extends UuidModel class EpisodeCommentModel extends UuidModel
{ {
/** /**
* @var string * @var class-string<EpisodeComment>
*/ */
protected $returnType = EpisodeComment::class; protected $returnType = EpisodeComment::class;
@ -86,11 +86,13 @@ class EpisodeCommentModel extends UuidModel
} }
if ($comment->in_reply_to_id === null) { if ($comment->in_reply_to_id === null) {
(new EpisodeModel())->builder() new EpisodeModel()
->builder()
->where('id', $comment->episode_id) ->where('id', $comment->episode_id)
->increment('comments_count'); ->increment('comments_count');
} else { } else {
(new self())->builder() new self()
->builder()
->where('id', service('uuid')->fromString($comment->in_reply_to_id)->getBytes()) ->where('id', service('uuid')->fromString($comment->in_reply_to_id)->getBytes())
->increment('replies_count'); ->increment('replies_count');
} }
@ -102,7 +104,7 @@ class EpisodeCommentModel extends UuidModel
'episode-comment', 'episode-comment',
esc($comment->actor->username), esc($comment->actor->username),
$comment->episode->slug, $comment->episode->slug,
$comment->id $comment->id,
); );
$createActivity = new CreateActivity(); $createActivity = new CreateActivity();
@ -180,7 +182,8 @@ class EpisodeCommentModel extends UuidModel
->where('id', $comment->episode_id) ->where('id', $comment->episode_id)
->decrement('comments_count'); ->decrement('comments_count');
} else { } else {
(new self())->builder() new self()
->builder()
->where('id', service('uuid')->fromString($comment->in_reply_to_id)->getBytes()) ->where('id', service('uuid')->fromString($comment->in_reply_to_id)->getBytes())
->decrement('replies_count'); ->decrement('replies_count');
} }
@ -201,7 +204,7 @@ class EpisodeCommentModel extends UuidModel
{ {
// TODO: merge with replies from posts linked to episode linked // TODO: merge with replies from posts linked to episode linked
$episodeCommentsBuilder = $this->builder(); $episodeCommentsBuilder = $this->builder();
$episodeComments = $episodeCommentsBuilder->select('*, 0 as is_from_post') $episodeComments = $episodeCommentsBuilder->select('*, 0 as is_private, 0 as is_from_post')
->where([ ->where([
'episode_id' => $episodeId, 'episode_id' => $episodeId,
'in_reply_to_id' => null, 'in_reply_to_id' => null,
@ -211,7 +214,7 @@ class EpisodeCommentModel extends UuidModel
$postModel = new PostModel(); $postModel = new PostModel();
$episodePostsRepliesBuilder = $postModel->builder(); $episodePostsRepliesBuilder = $postModel->builder();
$episodePostsReplies = $episodePostsRepliesBuilder->select( $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, 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 { ->whereIn('in_reply_to_id', static function (BaseBuilder $builder) use (&$episodeId): BaseBuilder {
return $builder->select('id') return $builder->select('id')
@ -221,19 +224,23 @@ class EpisodeCommentModel extends UuidModel
'in_reply_to_id' => null, 'in_reply_to_id' => null,
]); ]);
}) })
->where('`created_at` <= UTC_TIMESTAMP()', null, false) ->where('`created_at` <= UTC_TIMESTAMP()', null, false);
->getCompiledSelect();
// do not get private replies if public
if (! can_user_interact()) {
$episodePostsRepliesBuilder->where('is_private', false);
}
$episodePostsReplies = $episodePostsRepliesBuilder->getCompiledSelect();
/** @var BaseResult $allEpisodeComments */ /** @var BaseResult $allEpisodeComments */
$allEpisodeComments = $this->db->query( $allEpisodeComments = $this->db->query(
$episodeComments . ' UNION ' . $episodePostsReplies . ' ORDER BY created_at ASC' $episodeComments . ' UNION ' . $episodePostsReplies . ' ORDER BY created_at ASC',
); );
// FIXME:?
// @phpstan-ignore-next-line
return $this->convertUuidFieldsToStrings( return $this->convertUuidFieldsToStrings(
$allEpisodeComments->getCustomResultObject($this->tempReturnType), $allEpisodeComments->getCustomResultObject($this->tempReturnType),
$this->tempReturnType $this->tempReturnType,
); );
} }

View file

@ -87,8 +87,8 @@ class EpisodeModel extends UuidModel
'location_name', 'location_name',
'location_geo', 'location_geo',
'location_osm', 'location_osm',
'custom_rss',
'is_published_on_hubs', 'is_published_on_hubs',
'downloads_count',
'posts_count', 'posts_count',
'comments_count', 'comments_count',
'is_premium', 'is_premium',
@ -98,7 +98,7 @@ class EpisodeModel extends UuidModel
]; ];
/** /**
* @var string * @var class-string<Episode>
*/ */
protected $returnType = Episode::class; protected $returnType = Episode::class;
@ -197,7 +197,7 @@ class EpisodeModel extends UuidModel
public function getEpisodeByPreviewId(string $previewId): ?Episode public function getEpisodeByPreviewId(string $previewId): ?Episode
{ {
$cacheName = "podcast_episode#preview-{$previewId}"; $cacheName = "podcast_episode-preview#{$previewId}";
if (! ($found = cache($cacheName))) { if (! ($found = cache($cacheName))) {
$builder = $this->where([ $builder = $this->where([
'preview_id' => $this->uuid->fromString($previewId) 'preview_id' => $this->uuid->fromString($previewId)
@ -235,8 +235,8 @@ class EpisodeModel extends UuidModel
public function getPodcastEpisodes( public function getPodcastEpisodes(
int $podcastId, int $podcastId,
string $podcastType, string $podcastType,
string $year = null, ?string $year = null,
string $season = null ?string $season = null,
): array { ): array {
$cacheName = implode( $cacheName = implode(
'_', '_',
@ -347,7 +347,7 @@ class EpisodeModel extends UuidModel
{ {
$result = $this->builder() $result = $this->builder()
->select( ->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('podcast_id', $podcastId)
->where('`published_at` <= UTC_TIMESTAMP()', null, false) ->where('`published_at` <= UTC_TIMESTAMP()', null, false)
@ -368,29 +368,32 @@ class EpisodeModel extends UuidModel
public function resetCommentsCount(): int | false public function resetCommentsCount(): int | false
{ {
$episodeCommentsCount = (new EpisodeCommentModel())->builder() $episodeCommentsCount = new EpisodeCommentModel()
->builder()
->select('episode_id, COUNT(*) as `comments_count`') ->select('episode_id, COUNT(*) as `comments_count`')
->where('in_reply_to_id', null) ->where('in_reply_to_id')
->groupBy('episode_id') ->groupBy('episode_id')
->getCompiledSelect(); ->getCompiledSelect();
$episodePostsRepliesCount = (new PostModel())->builder() $episodePostsRepliesCount = new PostModel()
->builder()
->select('fediverse_posts.episode_id as episode_id, COUNT(*) as `comments_count`') ->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') ->join('fediverse_posts as fp', 'fediverse_posts.id = fp.in_reply_to_id')
->where('fediverse_posts.in_reply_to_id', null) ->where('fediverse_posts.in_reply_to_id')
->where('fediverse_posts.episode_id IS NOT', null) ->where('fediverse_posts.episode_id IS NOT')
->groupBy('fediverse_posts.episode_id') ->groupBy('fediverse_posts.episode_id')
->getCompiledSelect(); ->getCompiledSelect();
/** @var BaseResult $query */ /** @var BaseResult $query */
$query = $this->db->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(); $countsPerEpisodeId = $query->getResultArray();
if ($countsPerEpisodeId !== []) { if ($countsPerEpisodeId !== []) {
return (new self())->updateBatch($countsPerEpisodeId, 'id'); return new self()
->updateBatch($countsPerEpisodeId, 'id');
} }
return 0; return 0;
@ -401,7 +404,7 @@ class EpisodeModel extends UuidModel
$episodePostsCount = $this->builder() $episodePostsCount = $this->builder()
->select('episodes.id, COUNT(*) as `posts_count`') ->select('episodes.id, COUNT(*) as `posts_count`')
->join('fediverse_posts', 'episodes.id = fediverse_posts.episode_id') ->join('fediverse_posts', 'episodes.id = fediverse_posts.episode_id')
->where('in_reply_to_id', null) ->where('in_reply_to_id')
->groupBy('episodes.id') ->groupBy('episodes.id')
->get() ->get()
->getResultArray(); ->getResultArray();
@ -429,7 +432,8 @@ class EpisodeModel extends UuidModel
} }
/** @var ?Episode $episode */ /** @var ?Episode $episode */
$episode = (new self())->find($episodeId); $episode = new self()
->find($episodeId);
if (! $episode instanceof Episode) { if (! $episode instanceof Episode) {
return $data; return $data;
@ -441,7 +445,7 @@ class EpisodeModel extends UuidModel
cache() cache()
->deleteMatching("podcast-{$episode->podcast->handle}*"); ->deleteMatching("podcast-{$episode->podcast->handle}*");
cache() cache()
->delete("podcast_episode#{$episode->id}"); ->deleteMatching('podcast_episode*');
cache() cache()
->deleteMatching("page_podcast#{$episode->podcast_id}*"); ->deleteMatching("page_podcast#{$episode->podcast_id}*");
cache() cache()
@ -480,7 +484,7 @@ class EpisodeModel extends UuidModel
') ')
->select("{$podcastTable}.created_at AS podcast_created_at") ->select("{$podcastTable}.created_at AS podcast_created_at")
->select( ->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") ->join($podcastTable, "{$podcastTable} on {$podcastTable}.id = {$episodeTable}.podcast_id")
->where(' ->where('
@ -489,7 +493,7 @@ class EpisodeModel extends UuidModel
. 'OR' . . 'OR' .
$podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query) $podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query)
. ') . ')
'); ', );
return $this->builder; return $this->builder;
} }
@ -523,7 +527,8 @@ class EpisodeModel extends UuidModel
} }
/** @var ?Episode $episode */ /** @var ?Episode $episode */
$episode = (new self())->find($episodeId); $episode = new self()
->find($episodeId);
if (! $episode instanceof Episode) { if (! $episode instanceof Episode) {
return $data; return $data;

View file

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

View file

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

View file

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

View file

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

View file

@ -38,8 +38,6 @@ class PodcastModel extends Model
'handle', 'handle',
'description_markdown', 'description_markdown',
'description_html', 'description_html',
'episode_description_footer_markdown',
'episode_description_footer_html',
'cover_id', 'cover_id',
'banner_id', 'banner_id',
'language_code', 'language_code',
@ -47,10 +45,8 @@ class PodcastModel extends Model
'parental_advisory', 'parental_advisory',
'owner_name', 'owner_name',
'owner_email', 'owner_email',
'is_owner_email_removed_from_feed',
'publisher', 'publisher',
'type', 'type',
'medium',
'copyright', 'copyright',
'imported_feed_url', 'imported_feed_url',
'new_feed_url', 'new_feed_url',
@ -60,13 +56,7 @@ class PodcastModel extends Model
'location_name', 'location_name',
'location_geo', 'location_geo',
'location_osm', 'location_osm',
'verify_txt',
'payment_pointer',
'custom_rss',
'is_published_on_hubs', 'is_published_on_hubs',
'partner_id',
'partner_link_url',
'partner_image_url',
'is_premium_by_default', 'is_premium_by_default',
'published_at', 'published_at',
'created_by', 'created_by',
@ -74,7 +64,7 @@ class PodcastModel extends Model
]; ];
/** /**
* @var string * @var class-string<Podcast>
*/ */
protected $returnType = Podcast::class; protected $returnType = Podcast::class;
@ -173,7 +163,7 @@ class PodcastModel extends Model
/** /**
* @return Podcast[] * @return Podcast[]
*/ */
public function getAllPodcasts(string $orderBy = null): array public function getAllPodcasts(?string $orderBy = null): array
{ {
$prefix = $this->db->getPrefix(); $prefix = $this->db->getPrefix();
@ -185,8 +175,8 @@ class PodcastModel extends Model
->where( ->where(
'`' . $prefix . 'fediverse_posts`.`published_at` <= UTC_TIMESTAMP()', '`' . $prefix . 'fediverse_posts`.`published_at` <= UTC_TIMESTAMP()',
null, null,
false false,
)->orWhere('fediverse_posts.published_at', null) )->orWhere('fediverse_posts.published_at')
->groupEnd() ->groupEnd()
->groupBy('podcasts.actor_id') ->groupBy('podcasts.actor_id')
->orderBy('max_published_at', 'DESC'); ->orderBy('max_published_at', 'DESC');
@ -319,7 +309,8 @@ class PodcastModel extends Model
]; ];
} }
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode($podcastId); $secondsToNextUnpublishedEpisode = new EpisodeModel()
->getSecondsToNextUnpublishedEpisode($podcastId);
cache() cache()
->save($cacheName, $defaultQuery, $secondsToNextUnpublishedEpisode ?: DECADE); ->save($cacheName, $defaultQuery, $secondsToNextUnpublishedEpisode ?: DECADE);
@ -335,7 +326,8 @@ class PodcastModel extends Model
*/ */
public function clearCache(array $data): array public function clearCache(array $data): array
{ {
$podcast = (new self())->getPodcastById((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 // delete cache for users' podcasts
cache() cache()
@ -399,7 +391,8 @@ class PodcastModel extends Model
$domain = $domain =
$url->getHost() . ($url->getPort() ? ':' . $url->getPort() : ''); $url->getHost() . ($url->getPort() ? ':' . $url->getPort() : '');
$actorId = (new ActorModel())->insert( $actorId = new ActorModel()
->insert(
[ [
'uri' => url_to('podcast-activity', $username), 'uri' => url_to('podcast-activity', $username),
'username' => $username, 'username' => $username,
@ -427,10 +420,12 @@ class PodcastModel extends Model
*/ */
protected function setActorAvatar(array $data): array protected function setActorAvatar(array $data): array
{ {
$podcast = (new self())->getPodcastById((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) { if ($podcast instanceof Podcast) {
$podcastActor = (new ActorModel())->find($podcast->actor_id); $podcastActor = new ActorModel()
->find($podcast->actor_id);
if (! $podcastActor instanceof Actor) { if (! $podcastActor instanceof Actor) {
return $data; return $data;
@ -439,7 +434,8 @@ class PodcastModel extends Model
$podcastActor->avatar_image_url = $podcast->cover->federation_url; $podcastActor->avatar_image_url = $podcast->cover->federation_url;
$podcastActor->avatar_image_mimetype = $podcast->cover->federation_mimetype; $podcastActor->avatar_image_mimetype = $podcast->cover->federation_mimetype;
(new ActorModel())->update($podcast->actor_id, $podcastActor); new ActorModel()
->update($podcast->actor_id, $podcastActor);
} }
return $data; return $data;
@ -452,7 +448,8 @@ class PodcastModel extends Model
*/ */
protected function updatePodcastActor(array $data): array protected function updatePodcastActor(array $data): array
{ {
$podcast = (new self())->getPodcastById((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) { if ($podcast instanceof Podcast) {
$actorModel = new ActorModel(); $actorModel = new ActorModel();
@ -488,7 +485,7 @@ class PodcastModel extends Model
{ {
if (! array_key_exists( if (! array_key_exists(
'guid', 'guid',
$data['data'] $data['data'],
) || $data['data']['guid'] === null || $data['data']['guid'] === '') { ) || $data['data']['guid'] === null || $data['data']['guid'] === '') {
$uuid = service('uuid'); $uuid = service('uuid');
$feedUrl = url_to('podcast-rss-feed', $data['data']['handle']); $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 class PostModel extends FediversePostModel
{ {
/** /**
* @var string * @var class-string<Post>
*/ */
protected $returnType = Post::class; protected $returnType = Post::class;
@ -32,6 +32,7 @@ class PostModel extends FediversePostModel
'episode_id', 'episode_id',
'message', 'message',
'message_html', 'message_html',
'is_private',
'favourites_count', 'favourites_count',
'reblogs_count', 'reblogs_count',
'replies_count', 'replies_count',
@ -49,7 +50,7 @@ class PostModel extends FediversePostModel
return $this->where([ return $this->where([
'episode_id' => $episodeId, 'episode_id' => $episodeId,
]) ])
->where('in_reply_to_id', null) ->where('in_reply_to_id')
->where('`published_at` <= UTC_TIMESTAMP()', null, false) ->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->orderBy('published_at', 'DESC') ->orderBy('published_at', 'DESC')
->findAll(); ->findAll();

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
/**
* @icon('funding:buymeacoffee')
* @icon('funding:donorbox')
* @icon('funding:gofundme')
* @icon('funding:helloasso')
* @icon('funding:indiegogo')
* @icon('funding:kickstarter')
* @icon('funding:kisskissbankbank')
* @icon('funding:kofi')
* @icon('funding:liberapay')
* @icon('funding:patreon')
* @icon('funding:paypal')
* @icon('funding:tipeee')
* @icon('funding:ulule')
*/

View file

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
/**
* @icon('podcasting:amazon')
* @icon('podcasting:antennapod')
* @icon('podcasting:anytime')
* @icon('podcasting:apple')
* @icon('podcasting:blubrry')
* @icon('podcasting:breez')
* @icon('podcasting:castamatic')
* @icon('podcasting:castbox')
* @icon('podcasting:castopod')
* @icon('podcasting:castro')
* @icon('podcasting:deezer')
* @icon('podcasting:episodes-fm')
* @icon('podcasting:fountain')
* @icon('podcasting:fyyd')
* @icon('podcasting:gpodder')
* @icon('podcasting:ivoox')
* @icon('podcasting:listennotes')
* @icon('podcasting:overcast')
* @icon('podcasting:playerfm')
* @icon('podcasting:plink')
* @icon('podcasting:pocketcasts')
* @icon('podcasting:podbean')
* @icon('podcasting:podcastaddict')
* @icon('podcasting:podcastguru')
* @icon('podcasting:podcastindex')
* @icon('podcasting:podchaser')
* @icon('podcasting:podcloud')
* @icon('podcasting:podfriend')
* @icon('podcasting:podinstall')
* @icon('podcasting:podlink')
* @icon('podcasting:podlp')
* @icon('podcasting:podnews')
* @icon('podcasting:podtail')
* @icon('podcasting:podverse')
* @icon('podcasting:radiopublic')
* @icon('podcasting:sphinxchat')
* @icon('podcasting:spotify')
* @icon('podcasting:spreaker')
* @icon('podcasting:truefans')
* @icon('podcasting:tsacdop')
* @icon('podcasting:tunein')
* @icon('podcasting:youtube-music')
*/

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
/**
* @icon('social:bluesky')
* @icon('social:discord')
* @icon('social:facebook')
* @icon('social:funkwhale')
* @icon('social:instagram')
* @icon('social:linkedin')
* @icon('social:mastodon')
* @icon('social:matrix')
* @icon('social:misskey')
* @icon('social:mobilizon')
* @icon('social:peertube')
* @icon('social:pixelfed')
* @icon('social:pleroma')
* @icon('social:plume')
* @icon('social:slack')
* @icon('social:telegram')
* @icon('social:threads')
* @icon('social:tiktok')
* @icon('social:twitch')
* @icon('social:writefreely')
* @icon('social:x')
* @icon('social:youtube')
*/

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 59 40">
<path d="M52.801 38.23h-8.853s-.93-1.975-1.306-2.671c-.378-.697-1.278-.668-1.278-.668H17.33s-.871-.116-1.307.668c-.464.784-1.335 2.67-1.335 2.67H5.98c-2.003 0-3.658-1.625-3.658-3.628V5.893c0-2.003 1.626-3.658 3.629-3.658h46.821c2.003 0 3.658 1.626 3.658 3.629v28.708c.029 2.003-1.626 3.657-3.629 3.657Z" fill="#009486"/>
<path d="M17.01 10.014h24.703c4.267-.028 7.721 3.426 7.692 7.722 0 4.238-3.454 7.692-7.692 7.692H17.01c-4.238 0-7.692-3.454-7.692-7.692 0-4.238 3.454-7.722 7.692-7.722Z" fill="#E7F9E4"/>
<path d="M39.768 14.804a3.773 3.773 0 0 0-3.774 3.777c0 .861.298 1.656.795 2.319 0 0 1.29-.96 3.111-.96 1.357 0 2.946.827 2.946.827.43-.63.695-1.358.695-2.186a3.773 3.773 0 0 0-3.773-3.777Zm-20.9 0a3.773 3.773 0 0 0-3.774 3.777c0 .828.265 1.557.695 2.186 0 0 1.59-.828 2.946-.828 1.821 0 3.112.96 3.112.96a3.707 3.707 0 0 0 .794-2.318 3.773 3.773 0 0 0-3.773-3.777Z" fill="#009486"/>
</svg>

Before

Width:  |  Height:  |  Size: 966 B

View file

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 59 40">
<path d="M53.024 38.23H43.55s-.35-.726-.728-1.423c-.38-.697-1.283-.668-1.283-.668H17.403s-.875-.116-1.312.668a16.505 16.505 0 0 0-.758 1.422H6.005c-2.011 0-3.673-1.625-3.673-3.628V5.893c0-2.003 1.632-3.658 3.644-3.658h47.02c2.01 0 3.672 1.626 3.672 3.629v28.708c.03 2.003-1.632 3.657-3.644 3.657Z" fill="#009486"/>
<path d="M41.889 10.014H17.082c-4.256 0-7.725 3.484-7.725 7.722s3.47 7.692 7.725 7.692h24.807c4.256 0 7.725-3.454 7.725-7.692a7.655 7.655 0 0 0-7.725-7.722Zm-21.98 9.928s-1.136-.842-2.74-.842c-1.195 0-2.594.726-2.594.726a3.333 3.333 0 0 1-.612-1.916 3.315 3.315 0 0 1 3.323-3.31 3.315 3.315 0 0 1 3.323 3.31c0 .784-.262 1.48-.7 2.032Zm9.591 1.916c-3.644 0-3.498-2.787-3.498-2.787-.058-.522.612-.697.845-.377.117.174.117.174.204.493.496 1.713 2.449 1.626 2.449 1.626s1.953.116 2.449-1.626c.087-.29.087-.32.204-.493.233-.29.903-.145.845.377 0 0 .146 2.787-3.498 2.787Zm14.896-2.003s-1.4-.726-2.595-.726c-1.603 0-2.74.842-2.74.842a3.238 3.238 0 0 1-.7-2.032 3.315 3.315 0 0 1 3.324-3.31 3.315 3.315 0 0 1 3.323 3.31 3.014 3.014 0 0 1-.612 1.916Z" fill="#E7F9E4"/>
<path d="M41.714 14.63a3.315 3.315 0 0 0-3.323 3.309c0 .755.262 1.451.7 2.032 0 0 1.136-.842 2.74-.842 1.195 0 2.594.726 2.594.726.379-.552.612-1.19.612-1.916a3.315 3.315 0 0 0-3.323-3.31ZM17.286 14.63a3.315 3.315 0 0 0-3.323 3.309c0 .726.233 1.364.612 1.916 0 0 1.4-.726 2.594-.726 1.604 0 2.74.842 2.74.842.438-.552.7-1.248.7-2.032a3.315 3.315 0 0 0-3.323-3.31Z" fill="#009486"/>
<path d="M13.73 6.763c-1.837-.493-3.41.61-4.285 2.12-.204.348-.059.638.145.754.292.087.496.03.817-.435.641-1.132 1.72-1.77 2.944-1.539 0 0 .845.262.962-.29.087-.348-.233-.522-.583-.61ZM46.611 7.925c-.03.551.845.551.845.551 1.225.116 1.983.668 2.274 1.945.175.551.35.639.67.639.234-.03.467-.29.35-.697-.408-1.684-1.486-2.845-3.41-2.874-.35-.029-.7.058-.729.436Z" fill="#E7F9E4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,40 +0,0 @@
<svg width="232" height="260" viewBox="0 0 232 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M79.5436 200.875L141.5 200.376C143.831 200.376 145.664 198.377 145.664 196.045C145.664 193.714 143.665 191.882 141.333 191.882L79.3771 192.381C77.0454 192.381 75.2134 194.38 75.2134 196.712C75.3799 198.877 77.212 200.875 79.5436 200.875Z" fill="#006D60"/>
<path d="M135.171 191.882L86.0393 192.215C86.0393 192.215 98.5305 194.38 111.355 194.213C124.512 194.213 135.171 191.882 135.171 191.882Z" fill="#00564A"/>
<path d="M166.443 169.873L54.356 170.738L54.1786 147.754L166.266 146.889L166.443 169.873Z" fill="#00564A"/>
<path d="M50.5645 170.064C50.731 180.889 59.5581 189.716 70.3838 189.716L150.66 189.05C161.486 189.05 170.146 179.89 170.146 169.064V165.9C170.146 163.235 167.981 161.07 165.317 161.07L55.3944 161.903C52.7296 161.903 50.5645 164.068 50.5645 166.733V170.064Z" fill="#009486"/>
<path d="M164.987 156.203L55.3978 157.049L55.3952 156.716L164.984 155.87L164.987 156.203Z" fill="#2B6B5F"/>
<path d="M165.008 157.622L55.419 158.468L55.4164 158.135L165.005 157.289L165.008 157.622Z" fill="#2B6B5F"/>
<path d="M165.03 158.873L55.4407 159.719L55.4382 159.386L165.027 158.54L165.03 158.873Z" fill="#2B6B5F"/>
<path d="M111.688 189.383L150.494 189.05C161.319 188.884 170.146 179.89 169.98 169.064C169.98 169.064 168.314 177.891 156.323 182.388C147.496 185.719 135.004 185.053 111.521 185.22C88.0377 185.386 73.2149 186.219 64.2213 183.054C52.2297 178.724 50.3977 170.064 50.3977 170.064C50.5643 180.889 59.3913 189.716 70.217 189.716L111.688 189.383Z" fill="#006D60"/>
<path d="M72.0488 114.103L72.2154 111.938L77.0453 103.611C77.0453 103.611 77.5449 102.445 80.3763 102.611C81.7087 102.611 144.498 106.109 145.83 106.109C148.828 106.276 149.161 107.441 149.161 107.441L151.826 113.77L151.659 115.935L72.0488 114.103Z" fill="#00564A"/>
<path d="M79.0291 104.9L79.0472 104.567L146.731 108.252L146.713 108.584L79.0291 104.9Z" fill="#2B6B5F"/>
<path d="M77.7524 106.133L77.7705 105.8L147.949 109.621L147.931 109.953L77.7524 106.133Z" fill="#2B6B5F"/>
<path d="M45.2346 103.944L72.5486 105.443L74.8803 101.446C76.0462 99.447 78.711 99.7801 78.711 99.7801L148.162 103.611C148.162 103.611 150.66 103.444 151.826 105.776C152.992 108.108 153.824 109.94 153.824 109.94L180.639 111.439C186.468 111.772 191.464 107.275 191.798 101.446L196.294 18.5043C196.628 12.6751 192.131 7.67862 186.301 7.34552L51.2304 0.0173753C45.4012 -0.315722 40.4047 4.1811 40.0716 10.0103L35.5748 92.9516C34.9086 98.6143 39.4054 103.611 45.2346 103.944Z" fill="#009486"/>
<path d="M149.994 72.6327L77.7117 68.6355C77.7117 68.6355 95.8655 72.7992 114.685 73.7985C134.172 74.9644 149.994 72.6327 149.994 72.6327Z" fill="#006D60"/>
<path d="M58.7252 45.3186C58.059 57.6432 67.5523 68.1358 79.7103 68.802L150.993 72.6326C163.318 73.2988 173.81 63.9721 174.477 51.6475C175.143 39.3229 165.649 28.8303 153.491 28.1641L82.2085 24.3335C69.8839 23.5007 59.3914 32.994 58.7252 45.3186Z" fill="#E7F9E4"/>
<path d="M171.334 27.0569C169.538 22.3913 165.594 18.6476 160.122 19.1365C159.124 19.3068 158.18 19.6345 158.138 20.5304C158.158 22.1103 160.681 21.7632 160.681 21.7632C164.211 21.7723 167.082 23.949 168.338 27.5679C169.041 29.0867 169.622 29.2375 170.464 29.1218C171.305 29.0061 171.716 28.1582 171.334 27.0569Z" fill="#E7F9E4"/>
<path d="M71.2163 105.443L45.2347 104.11C39.4055 103.777 34.9087 98.6143 35.4083 92.785L38.5727 36.325L41.4041 92.785C41.4041 92.785 40.4048 98.9473 49.7315 100.946L71.2163 105.443Z" fill="#006D60"/>
<path d="M125.486 54.405C125.486 54.405 120.823 53.9054 115.66 54.405C110.497 54.9046 107.832 56.0705 107.832 56.0705C106.833 56.0705 106 55.4043 106 54.5715C106 53.5723 106.5 53.0726 107.499 52.7395C107.499 52.7395 112.495 51.4071 115.493 51.074C118.491 50.9075 125.32 51.074 125.32 51.074C126.319 51.074 127.152 51.7402 127.152 52.573C126.985 53.4057 126.319 54.2384 125.486 54.405Z" fill="#006D60"/>
<ellipse cx="148" cy="48" rx="7" ry="9" fill="#006D60"/>
<ellipse cx="86" cy="44" rx="7" ry="9" fill="#006D60"/>
<path d="M74.5763 17.4708C74.5763 17.4708 77.0259 17.961 77.1895 16.2952C77.2222 15.2827 76.1117 14.8906 75.2625 14.8905C69.8079 14.1386 66.182 16.9798 64.3849 21.7157C63.9928 22.8262 64.5154 23.6102 65.1686 23.7409C66.1485 23.937 66.5404 23.6757 67.3572 22.1407C68.7621 18.5153 71.0487 17.2744 74.5763 17.4708Z" fill="#E7F9E4"/>
<path d="M178.807 139.086C180.472 138.919 181.805 136.421 181.638 133.423L181.305 123.097C181.138 119.266 178.807 117.601 169.48 118.1C166.815 118.267 159.487 118.767 159.487 118.767C157.822 118.933 156.489 121.431 156.656 124.429L157.655 140.418C158.321 140.418 178.807 139.086 178.807 139.086Z" fill="#006D60"/>
<path d="M213.194 166.667L214.283 166.218C219.791 163.82 222.03 156.274 219.633 150.766L212.03 132.627C209.337 126.094 199.032 113.145 189.668 117.005L187.491 117.902C181.983 120.3 183.714 124.809 186.111 130.317L198.894 160.714C201.228 166.376 207.75 168.911 213.194 166.667Z" fill="#009486"/>
<path d="M213.194 166.667C213.194 166.667 207.161 169.026 201.764 159.022C199.533 154.846 188.429 128.215 187.455 124.923C186.159 120.234 187.491 117.902 187.491 117.902C181.983 120.3 183.714 124.809 186.111 130.317L198.894 160.714C201.228 166.376 207.75 168.911 213.194 166.667Z" fill="#006D60"/>
<path d="M215.827 144.552L203.234 149.838L203.363 150.145L215.956 144.859L215.827 144.552Z" fill="#71AFA3"/>
<path d="M216.894 147.07L204.301 152.356L204.43 152.663L217.023 147.377L216.894 147.07Z" fill="#71AFA3"/>
<path d="M217.89 149.432L205.298 154.718L205.427 155.025L218.019 149.739L217.89 149.432Z" fill="#71AFA3"/>
<path d="M42.403 136.421C40.7375 136.254 39.5716 133.589 40.0713 130.592L41.0706 120.432C41.5702 116.601 43.9019 115.103 53.2286 116.268C55.8934 116.601 63.055 117.601 63.055 117.601C64.7205 117.767 65.8864 120.432 65.3867 123.43L63.2216 139.252C62.8885 139.252 42.403 136.421 42.403 136.421Z" fill="#006D60"/>
<path d="M12.5417 160.989L11.4921 160.606C5.74762 158.749 3.05655 151.346 4.9138 145.601L11.1425 126.784C13.4015 120.155 22.5527 106.368 32.1268 109.463L34.3915 110.21C40.1359 112.067 38.6615 116.762 36.8042 122.507L26.4782 153.862C24.4744 159.791 18.2862 162.846 12.5417 160.989Z" fill="#009486"/>
<path d="M12.5417 160.989C12.5417 160.989 18.6361 162.974 23.2915 152.383C25.1348 147.981 34.2416 120.621 34.8557 117.198C35.8148 112.394 34.3915 110.21 34.3915 110.21C40.136 112.067 38.6615 116.762 36.8043 122.507L26.4782 153.863C24.4744 159.791 18.2862 162.846 12.5417 160.989Z" fill="#006D60"/>
<path d="M8.11157 139.649L21.0859 143.913L21.1899 143.596L8.21557 139.332L8.11157 139.649Z" fill="#71AFA3"/>
<path d="M7.22778 142.062L20.2021 146.326L20.3061 146.009L7.33178 141.745L7.22778 142.062Z" fill="#71AFA3"/>
<path d="M6.50293 144.527L19.4772 148.791L19.5812 148.475L6.60693 144.211L6.50293 144.527Z" fill="#71AFA3"/>
<path d="M58.7251 152.076C47.5663 151.91 45.4012 147.746 45.4012 136.92C45.4012 136.92 45.0681 118.767 45.0681 116.601C45.0681 112.937 52.5628 110.106 59.891 110.273L162.818 112.438C170.146 112.604 177.474 115.602 177.308 119.433C177.308 121.598 176.142 139.585 176.142 139.585C175.642 150.411 173.311 154.408 162.152 154.241L58.7251 152.076Z" fill="#009486"/>
<path d="M110.355 260C159.198 260 198.793 255.004 198.793 248.841C198.793 242.678 159.198 237.682 110.355 237.682C61.5125 237.682 21.9177 242.678 21.9177 248.841C21.9177 255.004 61.5125 260 110.355 260Z" fill="#000000" opacity=".05"/>
<path d="M93.7003 129.426V128.26H94.8662C94.8662 128.427 94.8662 128.427 94.6996 128.926V129.259H96.3651L96.8648 129.759C96.8648 129.926 96.8648 129.926 96.6982 130.259C96.6982 131.591 96.5317 132.59 96.3651 132.923C96.1986 133.423 95.8655 133.59 95.1993 133.59H94.3665C94.3665 133.09 94.3665 132.923 94.2 132.59C94.5331 132.757 94.8662 132.757 95.0327 132.757C95.3658 132.757 95.3658 132.59 95.5324 132.091C95.5324 131.758 95.6989 130.925 95.6989 130.425H94.5331C94.5331 130.758 94.5331 130.758 94.3665 131.091C94.0334 132.257 93.5338 133.09 92.2014 133.923C91.8683 133.423 91.7017 133.256 91.3687 132.923C92.2014 132.59 92.701 132.091 93.0341 131.424C93.2007 131.091 93.2007 130.925 93.3672 130.425H91.7017V129.426H93.7003ZM102.028 133.923C101.528 133.256 101.028 132.757 100.362 132.091C99.6961 132.757 99.0299 133.256 98.0306 133.756C97.8641 133.256 97.6975 133.09 97.3644 132.757C98.1971 132.424 98.8633 132.091 99.363 131.591C99.8626 131.091 100.362 130.592 100.695 129.925H98.1971V128.926H101.695L102.194 129.426C102.028 129.592 102.028 129.592 102.028 129.759C101.695 130.425 101.528 130.925 101.028 131.424C101.695 131.924 102.028 132.257 102.861 133.09L102.028 133.923ZM104.026 133.923V132.923L104.193 129.426V128.593H105.359V130.259C106.358 130.592 107.191 130.925 108.024 131.591L107.357 132.59C106.858 132.091 106.025 131.758 105.525 131.424C105.359 131.258 105.359 131.258 105.192 131.258V133.756L104.026 133.923ZM108.69 130.925C109.023 130.925 109.356 130.925 109.856 131.091H113.853V132.257H108.69V130.925ZM116.351 131.424V131.591C116.184 132.59 115.851 133.256 115.352 133.923L114.352 133.423C114.852 132.757 115.185 131.924 115.352 131.091L116.351 131.424ZM120.015 130.925H117.85V134.422H116.684V130.758H114.685V129.759H116.851V128.926H118.016V129.759H119.349C119.182 129.592 119.182 129.426 119.182 129.259C119.182 128.926 119.515 128.593 119.848 128.593C120.182 128.593 120.515 128.926 120.515 129.259C120.515 129.426 120.348 129.759 120.182 129.925L120.015 130.925ZM119.182 131.091C119.349 131.924 119.682 132.923 120.182 133.423L119.182 133.923C118.683 133.09 118.516 132.59 118.35 131.591C118.35 131.424 118.35 131.424 118.183 131.258L119.182 131.091ZM119.349 129.426C119.349 129.592 119.515 129.759 119.682 129.759C119.848 129.759 120.015 129.592 120.015 129.426C120.015 129.259 119.848 129.093 119.682 129.093C119.515 129.093 119.349 129.259 119.349 129.426ZM121.681 130.425L122.18 131.924L121.181 132.257C121.014 131.591 120.848 131.258 120.681 130.758L121.681 130.425ZM125.178 130.592C125.178 130.758 125.178 130.758 125.011 130.925C124.845 131.758 124.512 132.424 124.179 132.923C123.846 133.423 123.346 133.756 122.846 134.089C122.68 134.256 122.513 134.256 122.18 134.422C122.014 134.089 121.847 133.923 121.514 133.59C122.68 133.09 123.346 132.59 123.846 131.591C124.012 131.091 124.179 130.758 124.179 130.259L125.178 130.592ZM123.013 130.259C123.346 130.925 123.346 131.091 123.513 131.758L122.513 132.091L122.014 130.592L123.013 130.259ZM126.51 134.589V133.59L126.677 130.092V129.259H127.843V130.925C128.842 131.258 129.675 131.758 130.508 132.257L129.841 133.256C129.342 132.923 128.676 132.424 128.009 132.091C127.843 132.091 127.843 132.091 127.843 131.924V134.422L126.51 134.589ZM129.342 129.592C129.508 129.926 129.675 130.092 129.841 130.425L129.342 130.758C129.175 130.425 129.009 130.092 128.842 129.925L129.342 129.592ZM130.175 129.259C130.341 129.426 130.508 129.759 130.841 130.092L130.341 130.425C130.175 130.092 130.008 129.759 129.841 129.592L130.175 129.259Z" fill="#00564A"/>
<path d="M104.193 139.419C104.026 139.419 104.026 139.419 103.86 139.585H103.526C103.193 139.585 102.86 139.419 102.694 139.252C102.527 139.086 102.361 138.753 102.361 138.253C102.361 137.753 102.527 137.42 102.694 137.254C102.86 137.087 103.193 136.921 103.526 136.921H103.86C104.026 136.921 104.026 136.921 104.193 137.087V137.587C104.026 137.42 104.026 137.42 103.86 137.42H103.526C103.36 137.42 103.193 137.42 103.027 137.587C103.193 137.587 103.193 137.92 103.193 138.086C103.193 138.42 103.193 138.586 103.36 138.753C103.526 138.919 103.526 138.919 103.86 138.919H104.193C104.359 138.919 104.359 138.753 104.526 138.753L104.193 139.419ZM105.692 136.921H106.191V138.586H106.524V139.086H106.191V139.585H105.692V139.086H104.692V138.586L105.692 136.921ZM105.692 137.42L105.025 138.42H105.692V137.42ZM107.024 136.921H108.523V137.42H107.524V137.92H107.857C108.19 137.92 108.356 138.086 108.523 138.253C108.689 138.42 108.689 138.586 108.689 138.919C108.689 139.252 108.523 139.419 108.356 139.585C108.19 139.752 107.857 139.752 107.69 139.752H107.357C107.191 139.752 107.191 139.752 107.024 139.585V139.086C107.191 139.086 107.191 139.252 107.357 139.252H107.69C107.857 139.252 108.023 139.252 108.19 139.086C108.356 139.086 108.356 138.919 108.356 138.753C108.356 138.586 108.356 138.42 108.19 138.42C108.023 138.253 108.023 138.253 107.857 138.253H107.524C107.357 138.253 107.357 138.253 107.191 138.42L107.024 136.921ZM109.189 136.921H110.855V137.254L109.855 139.585H109.356L110.188 137.42H109.023L109.189 136.921ZM111.188 138.42C111.188 137.92 111.354 137.587 111.354 137.42C111.521 137.254 111.687 137.087 112.02 137.087C112.354 137.087 112.52 137.254 112.687 137.42C112.853 137.587 112.853 137.92 112.853 138.42C112.853 138.919 112.687 139.252 112.687 139.419C112.52 139.585 112.354 139.752 112.02 139.752C111.687 139.752 111.521 139.585 111.354 139.419C111.188 139.252 111.188 138.753 111.188 138.42ZM112.187 137.42C112.02 137.42 112.02 137.42 111.854 137.587C111.854 137.753 111.687 137.92 111.687 138.253V138.586L112.354 137.587C112.354 137.587 112.354 137.42 112.187 137.42ZM111.854 139.086C111.854 139.252 112.02 139.252 112.187 139.252C112.354 139.252 112.354 139.252 112.52 139.086C112.52 138.919 112.687 138.753 112.687 138.42V138.086L111.854 139.086ZM113.519 137.087H114.186C114.519 137.087 114.852 137.087 115.018 137.254C115.185 137.42 115.185 137.587 115.185 137.92C115.185 138.253 115.018 138.42 114.852 138.586C114.685 138.753 114.352 138.753 114.019 138.753H113.852V139.752H113.353L113.519 137.087ZM114.019 137.587V138.42H114.519C114.519 138.42 114.685 138.253 114.685 138.086C114.685 137.92 114.685 137.92 114.519 137.753C114.519 137.753 114.352 137.587 114.186 137.587H114.019ZM115.684 138.42C115.684 137.92 115.851 137.587 115.851 137.42C116.018 137.254 116.184 137.087 116.517 137.087C116.85 137.087 117.017 137.254 117.183 137.42C117.35 137.587 117.35 137.92 117.35 138.42C117.35 138.919 117.183 139.252 117.183 139.419C117.017 139.585 116.85 139.752 116.517 139.752C116.184 139.752 116.018 139.585 115.851 139.419C115.684 139.252 115.684 138.919 115.684 138.42ZM116.517 137.587C116.351 137.587 116.351 137.587 116.184 137.753C116.184 137.92 116.018 138.086 116.018 138.42V138.753L116.684 137.753C116.684 137.587 116.684 137.587 116.517 137.587ZM116.184 139.252C116.184 139.419 116.351 139.419 116.517 139.419C116.684 139.419 116.684 139.419 116.85 139.252C116.85 139.086 117.017 138.919 117.017 138.586V138.253L116.184 139.252C116.184 139.086 116.184 139.086 116.184 139.252ZM117.85 137.254H118.349C118.849 137.254 119.015 137.42 119.349 137.587C119.515 137.753 119.682 138.086 119.682 138.586C119.682 139.086 119.515 139.419 119.349 139.585C119.182 139.752 118.849 139.918 118.349 139.918H117.85V137.254ZM118.349 137.587V139.252H118.516C118.682 139.252 118.849 139.252 119.015 139.086C119.182 138.919 119.182 138.753 119.182 138.42C119.182 138.086 119.182 137.92 119.015 137.753C118.849 137.753 118.682 137.753 118.349 137.587Z" fill="#00564A"/>
<path d="M93.7003 129.093V127.927H94.8662C94.8662 128.093 94.8662 128.093 94.6996 128.593V128.926H96.3651L96.8648 129.426C96.8648 129.592 96.8648 129.592 96.6982 129.925C96.6982 131.258 96.5317 132.257 96.3651 132.59C96.1986 133.09 95.8655 133.256 95.1993 133.256H94.3665C94.3665 132.757 94.3665 132.59 94.2 132.257C94.5331 132.424 94.8662 132.424 95.0327 132.424C95.3658 132.424 95.3658 132.257 95.5324 131.757C95.5324 131.424 95.6989 130.592 95.6989 130.092H94.5331C94.5331 130.425 94.5331 130.425 94.3665 130.758C94.0334 131.924 93.5338 132.757 92.2014 133.59C91.8683 133.09 91.7017 132.923 91.3687 132.59C92.2014 132.257 92.701 131.758 93.0341 131.091C93.2007 130.758 93.2007 130.592 93.3672 130.092H91.7017V129.093H93.7003ZM102.028 133.59C101.528 132.923 101.028 132.424 100.362 131.757C99.6961 132.424 99.0299 132.923 98.0306 133.423C97.8641 132.923 97.6975 132.757 97.3644 132.424C98.1971 132.091 98.8633 131.758 99.363 131.258C99.8626 130.758 100.362 130.259 100.695 129.592H98.1971V128.593H101.695L102.194 129.093C102.028 129.259 102.028 129.259 102.028 129.426C101.695 130.092 101.528 130.592 101.028 131.091C101.695 131.591 102.028 131.924 102.861 132.757L102.028 133.59ZM104.026 133.59V132.59L104.193 129.093V128.26H105.359V129.925C106.358 130.259 107.191 130.592 108.024 131.258L107.357 132.257C106.858 131.757 106.025 131.424 105.525 131.091C105.359 130.925 105.359 130.925 105.192 130.925V133.423L104.026 133.59ZM108.69 130.425C109.023 130.425 109.356 130.425 109.856 130.592H113.853V131.757H108.69V130.425ZM116.351 130.925V131.091C116.184 132.091 115.851 132.757 115.352 133.423L114.352 132.923C114.852 132.257 115.185 131.424 115.352 130.592L116.351 130.925ZM120.015 130.592H117.85V134.089H116.684V130.425H114.685V129.426H116.851V128.593H118.016V129.426H119.349C119.182 129.259 119.182 129.093 119.182 128.926C119.182 128.593 119.515 128.26 119.848 128.26C120.182 128.26 120.515 128.593 120.515 128.926C120.515 129.093 120.348 129.426 120.182 129.592L120.015 130.592ZM119.182 130.758C119.349 131.591 119.682 132.59 120.182 133.09L119.182 133.59C118.683 132.757 118.516 132.257 118.35 131.258C118.35 131.091 118.35 131.091 118.183 130.925L119.182 130.758ZM119.349 129.093C119.349 129.259 119.515 129.426 119.682 129.426C119.848 129.426 120.015 129.259 120.015 129.093C120.015 128.926 119.848 128.76 119.682 128.76C119.515 128.76 119.349 128.926 119.349 129.093ZM121.681 130.092L122.18 131.591L121.181 131.924C121.014 131.258 120.848 130.925 120.681 130.425L121.681 130.092ZM125.178 130.259C125.178 130.425 125.178 130.425 125.011 130.592C124.845 131.424 124.512 132.091 124.179 132.59C123.846 133.09 123.346 133.423 122.846 133.756C122.68 133.923 122.513 133.923 122.18 134.089C122.014 133.756 121.847 133.59 121.514 133.256C122.68 132.757 123.346 132.257 123.846 131.258C124.012 130.758 124.179 130.425 124.179 129.925L125.178 130.259ZM123.013 129.925C123.346 130.592 123.346 130.758 123.513 131.424L122.513 131.757L122.014 130.259L123.013 129.925ZM126.51 134.256V133.256L126.677 129.759V128.926H127.843V130.592C128.842 130.925 129.675 131.424 130.508 131.924L129.841 132.923C129.342 132.59 128.676 132.091 128.009 131.757C127.843 131.757 127.843 131.757 127.843 131.591V134.089L126.51 134.256ZM129.342 129.259C129.508 129.592 129.675 129.759 129.841 130.092L129.342 130.425C129.175 130.092 129.009 129.759 128.842 129.592L129.342 129.259ZM130.175 128.926C130.341 129.093 130.508 129.426 130.841 129.759L130.341 130.092C130.175 129.759 130.008 129.426 129.841 129.259L130.175 128.926Z" fill="#E7F9E4"/>
<path d="M104.193 139.086C104.026 139.086 104.026 139.086 103.86 139.252H103.526C103.193 139.252 102.86 139.086 102.694 138.919C102.527 138.753 102.361 138.42 102.361 137.92C102.361 137.42 102.527 137.087 102.694 136.921C102.86 136.754 103.193 136.587 103.526 136.587H103.86C104.026 136.587 104.026 136.587 104.193 136.754V137.254C104.026 137.087 104.026 137.087 103.86 137.087H103.526C103.36 137.087 103.193 137.087 103.027 137.254C103.193 137.254 103.193 137.42 103.193 137.753C103.193 138.086 103.193 138.253 103.36 138.419C103.526 138.586 103.526 138.586 103.86 138.586H104.193C104.359 138.586 104.359 138.419 104.526 138.419L104.193 139.086ZM105.692 136.587H106.191V138.253H106.524V138.753H106.191V139.252H105.692V138.753H104.692V138.253L105.692 136.587ZM105.692 137.087L105.025 138.086H105.692V137.087ZM107.024 136.587H108.523V137.087H107.524V137.587H107.857C108.19 137.587 108.356 137.753 108.523 137.92C108.689 138.086 108.689 138.253 108.689 138.586C108.689 138.919 108.523 139.086 108.356 139.252C108.19 139.419 107.857 139.419 107.69 139.419H107.357C107.191 139.419 107.191 139.419 107.024 139.252V138.753C107.191 138.753 107.191 138.919 107.357 138.919H107.69C107.857 138.919 108.023 138.919 108.19 138.753C108.356 138.753 108.356 138.586 108.356 138.419C108.356 138.253 108.356 138.086 108.19 138.086C108.023 137.92 108.023 137.92 107.857 137.92H107.524C107.357 137.92 107.357 137.92 107.191 138.086L107.024 136.587ZM109.189 136.587H110.855V136.921L109.855 139.252H109.356L110.188 137.087H109.023L109.189 136.587ZM111.188 137.92C111.188 137.42 111.354 137.087 111.354 136.921C111.521 136.754 111.687 136.587 112.02 136.587C112.354 136.587 112.52 136.754 112.687 136.921C112.853 137.087 112.853 137.42 112.853 137.92C112.853 138.42 112.687 138.753 112.687 138.919C112.52 139.086 112.354 139.252 112.02 139.252C111.687 139.252 111.521 139.086 111.354 138.919C111.188 138.753 111.188 138.42 111.188 137.92ZM112.187 137.087C112.02 137.087 112.02 137.087 111.854 137.254C111.854 137.42 111.687 137.587 111.687 137.92V138.253L112.354 137.254C112.354 137.087 112.354 137.087 112.187 137.087ZM111.854 138.753C111.854 138.919 112.02 138.919 112.187 138.919C112.354 138.919 112.354 138.919 112.52 138.753C112.52 138.586 112.687 138.42 112.687 138.086V137.753L111.854 138.753ZM113.519 136.754H114.186C114.519 136.754 114.852 136.754 115.018 136.921C115.185 137.087 115.185 137.254 115.185 137.587C115.185 137.92 115.018 138.086 114.852 138.253C114.685 138.419 114.352 138.419 114.019 138.419H113.852V139.419H113.353L113.519 136.754ZM114.019 137.254V138.086H114.519C114.519 138.086 114.685 137.92 114.685 137.753C114.685 137.587 114.685 137.587 114.519 137.42C114.519 137.42 114.352 137.254 114.186 137.254H114.019ZM115.684 138.086C115.684 137.587 115.851 137.254 115.851 137.087C116.018 136.921 116.184 136.754 116.517 136.754C116.85 136.754 117.017 136.921 117.183 137.087C117.35 137.254 117.35 137.587 117.35 138.086C117.35 138.586 117.183 138.919 117.183 139.086C117.017 139.252 116.85 139.419 116.517 139.419C116.184 139.419 116.018 139.252 115.851 139.086C115.684 138.919 115.684 138.586 115.684 138.086ZM116.517 137.254C116.351 137.254 116.351 137.254 116.184 137.42C116.184 137.587 116.018 137.753 116.018 138.086V138.419L116.684 137.42C116.684 137.254 116.684 137.254 116.517 137.254ZM116.184 138.753C116.184 138.919 116.351 138.919 116.517 138.919C116.684 138.919 116.684 138.919 116.85 138.753C116.85 138.586 117.017 138.42 117.017 138.086V137.753L116.184 138.753ZM117.85 136.754H118.349C118.849 136.754 119.015 136.921 119.349 137.087C119.515 137.254 119.682 137.587 119.682 138.086C119.682 138.586 119.515 138.919 119.349 139.086C119.182 139.252 118.849 139.419 118.349 139.419H117.85V136.754ZM118.349 137.254V138.919H118.516C118.682 138.919 118.849 138.919 119.015 138.753C119.182 138.586 119.182 138.42 119.182 138.086C119.182 137.753 119.182 137.587 119.015 137.42C118.849 137.42 118.682 137.254 118.349 137.254Z" fill="#E7F9E4"/>
</svg>

Before

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,40 +0,0 @@
import "@github/markdown-toolbar-element";
import "@github/relative-time-element";
import "./modules/audio-clipper";
import ClientTimezone from "./modules/ClientTimezone";
import Clipboard from "./modules/Clipboard";
import DateTimePicker from "./modules/DateTimePicker";
import Dropdown from "./modules/Dropdown";
import HotKeys from "./modules/HotKeys";
import "./modules/markdown-preview";
import "./modules/markdown-write-preview";
import MultiSelect from "./modules/MultiSelect";
import "./modules/permalink-edit";
import "./modules/play-soundbite";
import PublishMessageWarning from "./modules/PublishMessageWarning";
import Select from "./modules/Select";
import SidebarToggler from "./modules/SidebarToggler";
import Slugify from "./modules/Slugify";
import ThemePicker from "./modules/ThemePicker";
import Time from "./modules/Time";
import Tooltip from "./modules/Tooltip";
import ValidateFileSize from "./modules/ValidateFileSize";
import "./modules/video-clip-previewer";
import VideoClipBuilder from "./modules/VideoClipBuilder";
import "./modules/xml-editor";
Dropdown();
Tooltip();
Select();
MultiSelect();
Slugify();
SidebarToggler();
ClientTimezone();
DateTimePicker();
Time();
Clipboard();
ThemePicker();
PublishMessageWarning();
HotKeys();
ValidateFileSize();
VideoClipBuilder();

View file

@ -1,5 +0,0 @@
import Dropdown from "./modules/Dropdown";
import Tooltip from "./modules/Tooltip";
Dropdown();
Tooltip();

View file

@ -1,3 +0,0 @@
import DrawCharts from "./modules/Charts";
DrawCharts();

View file

@ -1,3 +0,0 @@
import Tooltip from "./modules/Tooltip";
Tooltip();

View file

@ -1,3 +0,0 @@
import DrawEpisodesMaps from "./modules/EpisodesMap";
DrawEpisodesMaps();

View file

@ -1,128 +0,0 @@
import { indentWithTab } from "@codemirror/commands";
import { xml } from "@codemirror/lang-xml";
import {
defaultHighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
import { Compartment, EditorState } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { basicSetup, EditorView } from "codemirror";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, queryAssignedNodes, state } from "lit/decorators.js";
import prettifyXML from "xml-formatter";
const language = new Compartment();
@customElement("xml-editor")
export class XMLEditor extends LitElement {
@queryAssignedNodes({ slot: "textarea" })
_textarea!: NodeListOf<HTMLTextAreaElement>;
@state()
editorState!: EditorState;
@state()
editorView!: EditorView;
firstUpdated(): void {
const minHeightEditor = EditorView.theme({
".cm-content, .cm-gutter": {
minHeight: this._textarea[0].clientHeight + "px",
},
});
let editorContents = "";
if (this._textarea[0].value) {
try {
editorContents = prettifyXML(this._textarea[0].value, {
indentation: " ",
});
} catch (e) {
// xml doesn't have a root node
editorContents = prettifyXML(
"<root>" + this._textarea[0].value + "</root>",
{
indentation: " ",
}
);
// remove root, unnecessary lines and indents
editorContents = editorContents
.replace(/^<root>/, "")
.replace(/<\/root>$/, "")
.replace(/^\s*[\r\n]/gm, "")
.replace(/[\r\n] {2}/gm, "\r\n")
.trim();
}
}
this.editorState = EditorState.create({
doc: editorContents,
extensions: [
basicSetup,
keymap.of([indentWithTab]),
language.of(xml()),
minHeightEditor,
syntaxHighlighting(defaultHighlightStyle),
],
});
this.editorView = new EditorView({
state: this.editorState,
root: this.shadowRoot as ShadowRoot,
parent: this.shadowRoot as ShadowRoot,
});
this._textarea[0].hidden = true;
if (this._textarea[0].form) {
this._textarea[0].form.addEventListener("submit", () => {
this._textarea[0].value = this.editorView.state.doc.toString();
});
}
}
disconnectedCallback(): void {
if (this._textarea[0].form) {
this._textarea[0].form.removeEventListener("submit", () => {
this._textarea[0].value = this.editorView.state.doc.toString();
});
}
}
static styles = css`
.cm-editor {
border-radius: 0.5rem;
overflow: hidden;
border: 3px solid hsl(var(--color-border-contrast));
background-color: hsl(var(--color-background-elevated));
}
.cm-editor.cm-focused {
outline: 2px solid transparent;
box-shadow:
0 0 0 2px hsl(var(--color-background-elevated)),
0 0 0 calc(4px) hsl(var(--color-accent-base));
}
.cm-gutters {
background-color: hsl(var(--color-background-elevated)) !important;
}
.cm-activeLine {
background-color: hsl(var(--color-background-highlight)) !important;
}
.cm-activeLineGutter {
background-color: hsl(var(--color-background-highlight)) !important;
}
.ͼ4 .cm-line {
caret-color: hsl(var(--color-text-base)) !important;
}
.ͼ1 .cm-cursor {
border: none;
}
`;
render(): TemplateResult<1> {
return html`<slot name="textarea"></slot>`;
}
}

View file

@ -1,10 +0,0 @@
import "@github/relative-time-element";
import SidebarToggler from "./modules/SidebarToggler";
import Time from "./modules/Time";
import Toggler from "./modules/Toggler";
import Tooltip from "./modules/Tooltip";
Time();
Toggler();
Tooltip();
SidebarToggler();

View file

@ -1,15 +0,0 @@
@import url("./tailwind.css");
@import url("./custom.css");
@import url("./fonts.css");
@import url("./colors.css");
@import url("./breadcrumb.css");
@import url("./dropdown.css");
@import url("./choices.css");
@import url("./radioBtn.css");
@import url("./colorRadioBtn.css");
@import url("./switch.css");
@import url("./radioToggler.css");
@import url("./formInputTabs.css");
@import url("./stickyHeader.css");
@import url("./readMore.css");
@import url("./seeMore.css");

View file

@ -1,23 +0,0 @@
@layer components {
.form-radio-btn {
@apply absolute mt-3 ml-3 border-contrast border-3 text-accent-base;
&:focus {
@apply ring-accent;
}
&:checked {
@apply ring-2 ring-contrast;
& + label {
@apply text-accent-contrast bg-accent-base;
}
}
& + label {
@apply inline-flex items-center py-2 pl-8 pr-2 text-sm font-semibold rounded-lg cursor-pointer border-contrast bg-elevated border-3;
color: hsl(var(--color-text-muted));
}
}
}

View file

@ -11,13 +11,15 @@ declare(strict_types=1);
namespace App\Validation; namespace App\Validation;
use CodeIgniter\Validation\FileRules as ValidationFileRules; use CodeIgniter\Validation\FileRules as ValidationFileRules;
use Override;
class FileRules extends ValidationFileRules class FileRules extends ValidationFileRules
{ {
/** /**
* Checks an uploaded file to verify that the dimensions are within a specified allowable dimension. * 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 // Grab the file name off the top of the $params
// after we split it. // 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 * 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 // Grab the file name off the top of the $params
// after we split it. // after we split it.
@ -99,7 +101,7 @@ class FileRules extends ValidationFileRules
/** /**
* Checks that an uploaded json file's content is valid * 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 // Grab the file name off the top of the $params
// after we split it. // 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,58 +4,57 @@ declare(strict_types=1);
namespace App\Views\Components; namespace App\Views\Components;
use Override;
use ViewComponents\Component; use ViewComponents\Component;
class Alert extends 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' * @var 'default'|'success'|'danger'|'warning'
*/ */
protected string $variant = 'default'; protected string $variant = 'default';
#[Override]
public function render(): string public function render(): string
{ {
$variants = [ $variantData = match ($this->variant) {
'success' => [ 'success' => [
'class' => 'text-pine-900 bg-pine-100 border-pine-300', 'class' => 'text-pine-900 bg-pine-100 border-pine-300',
'glyph' => 'check-fill', // @icon('check-fill') 'glyph' => 'check-fill', // @icon("check-fill")
], ],
'danger' => [ 'danger' => [
'class' => 'text-red-900 bg-red-100 border-red-300', 'class' => 'text-red-900 bg-red-100 border-red-300',
'glyph' => 'close-fill', // @icon('close-fill') 'glyph' => 'close-fill', // @icon("close-fill")
], ],
'warning' => [ 'warning' => [
'class' => 'text-yellow-900 bg-yellow-100 border-yellow-300', 'class' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
'glyph' => 'alert-fill', // @icon('alert-fill') 'glyph' => 'alert-fill', // @icon("alert-fill")
], ],
'default' => [ default => [
'class' => 'text-blue-900 bg-blue-100 border-blue-300', 'class' => 'text-blue-900 bg-blue-100 border-blue-300',
'glyph' => 'error-warning-fill', // @icon('error-warning-fill') 'glyph' => 'error-warning-fill', // @icon("error-warning-fill")
], ],
]; };
if (! array_key_exists($this->variant, $variants)) { $glyph = icon(($this->glyph === '' ? $variantData['glyph'] : $this->glyph), [
$this->variant = 'default';
}
$glyph = icon(($this->glyph ?? $variants[$this->variant]['glyph']), [
'class' => 'flex-shrink-0 mr-2 text-lg', 'class' => 'flex-shrink-0 mr-2 text-lg',
]); ]);
$title = $this->title === null ? '' : '<div class="font-semibold">' . $this->title . '</div>'; $title = $this->title === '' ? '' : '<div class="font-semibold">' . $this->title . '</div>';
$class = 'inline-flex w-full p-2 text-sm border rounded ' . $variants[$this->variant]['class'] . ' ' . $this->class; $this->mergeClass('inline-flex w-full p-2 text-sm border rounded ');
$this->mergeClass($variantData['class']);
unset($this->attributes['slot']);
unset($this->attributes['variant']);
unset($this->attributes['class']);
unset($this->attributes['glyph']);
$attributes = stringify_attributes($this->attributes);
return <<<HTML 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; HTML;
} }
} }

View file

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

View file

@ -4,18 +4,22 @@ declare(strict_types=1);
namespace App\Views\Components\Charts; namespace App\Views\Components\Charts;
use Override;
use ViewComponents\Component; use ViewComponents\Component;
class ChartsComponent extends Component class ChartsComponent extends Component
{ {
protected string $title = ''; protected array $props = ['title', 'subtitle', 'dataUrl', 'type'];
protected string $title;
protected string $subtitle = ''; protected string $subtitle = '';
protected string $dataUrl = ''; protected string $dataUrl;
protected string $type = ''; protected string $type;
#[Override]
public function render(): string public function render(): string
{ {
$subtitleBlock = ''; $subtitleBlock = '';
@ -23,8 +27,10 @@ class ChartsComponent extends Component
$subtitleBlock = '<p class="px-6 -mt-4 text-sm text-skin-muted">' . $this->subtitle . '</p>'; $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 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> <h2 class="px-6 py-4 text-xl">{$this->title}</h2>
{$subtitleBlock} {$subtitleBlock}
<div class="w-full h-[500px]" data-chart-type="{$this->type}" data-chart-url="{$this->dataUrl}"></div> <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; namespace App\Views\Components;
use Override;
use ViewComponents\Component; use ViewComponents\Component;
class DashboardCard extends Component class DashboardCard extends Component
{ {
protected ?string $href = null; protected array $props = ['href', 'glyph', 'title', 'subtitle'];
protected string $href = '';
protected string $glyph; protected string $glyph;
@ -21,17 +24,18 @@ class DashboardCard extends Component
$this->subtitle = html_entity_decode($value); $this->subtitle = html_entity_decode($value);
} }
#[Override]
public function render(): string public function render(): string
{ {
$glyph = icon($this->glyph, [ $glyph = (string) icon($this->glyph, [
'class' => 'flex-shrink-0 bg-base rounded-full w-8 h-8 p-2 text-accent-base', 'class' => 'flex-shrink-0 bg-base rounded-full w-8 h-8 p-2 text-accent-base',
]); ]);
if ($this->href !== null && $this->href !== '') { if ($this->href !== '') {
$chevronRight = icon('arrow-right-s-fill'); $chevronRight = icon('arrow-right-s-fill');
$viewLang = lang('Common.view'); $viewLang = lang('Common.view');
return <<<HTML 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="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> <div class="text-5xl font-bold">{$this->slot}</div>
</a> </a>

View file

@ -5,27 +5,37 @@ declare(strict_types=1);
namespace App\Views\Components; namespace App\Views\Components;
use Exception; use Exception;
use Override;
use ViewComponents\Component; use ViewComponents\Component;
class DropdownMenu extends 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 public function setItems(string $value): void
{ {
$this->items = json_decode(htmlspecialchars_decode($value), true); $this->items = json_decode(htmlspecialchars_decode($value), true);
} }
#[Override]
public function render(): string public function render(): string
{ {
if ($this->items === []) { if ($this->items === []) {
@ -37,7 +47,7 @@ class DropdownMenu extends Component
switch ($item['type']) { switch ($item['type']) {
case 'link': case 'link':
$menuItems .= anchor($item['uri'], $item['title'], [ $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; break;
case 'html': 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 return <<<HTML
<nav id="{$this->id}" <nav {$this->getStringifiedAttributes()}>{$menuItems}</nav>
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>
HTML; HTML;
} }
} }

View file

@ -4,35 +4,59 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; namespace App\Views\Components\Forms;
use App\Views\Components\Hint;
use Override;
class Checkbox extends FormComponent 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';
}
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',
]; ];
protected string $hint = '';
protected string $helper = '';
#[Override]
public function render(): string
{
$checkboxInput = form_checkbox( $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', '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 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; 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; namespace App\Views\Components\Forms;
use Override;
class ColorRadioButton extends FormComponent 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 protected bool $isSelected = false;
{
$this->isChecked = $value === 'true';
}
#[Override]
public function render(): string public function render(): string
{ {
$data = [ $data = [
@ -23,18 +25,18 @@ class ColorRadioButton extends FormComponent
'class' => 'color-radio-btn', 'class' => 'color-radio-btn',
]; ];
if ($this->required) { if ($this->isRequired) {
$data['required'] = 'required'; $data['required'] = 'required';
} }
$radioInput = form_radio( $radioInput = form_radio(
$data, $data,
$this->value, $this->value,
old($this->name) ? old($this->name) === $this->value : $this->isChecked, old($this->name) ? old($this->name) === $this->value : $this->isSelected,
); );
return <<<HTML return <<<HTML
<div class="{$this->class}" style="{$this->style}"> <div {$this->getStringifiedAttributes()}>
{$radioInput} {$radioInput}
<label for="{$this->value}" title="{$this->slot}" data-tooltip="bottom"></label> <label for="{$this->value}" title="{$this->slot}" data-tooltip="bottom"></label>
</div> </div>

View file

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

View file

@ -4,51 +4,80 @@ declare(strict_types=1);
namespace App\Views\Components\Forms; 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 $as = 'Input';
protected string $label = ''; protected string $hint = '';
protected ?string $helper = null; protected string $helper = '';
protected ?string $hint = null;
#[Override]
public function render(): string public function render(): string
{ {
$helperText = ''; $helperText = '';
if ($this->helper !== null) { if ($this->helper !== '') {
$helperId = $this->id . 'Help'; $helperId = $this->name . 'Help';
$helperText = '<Forms.Helper id="' . $helperId . '">' . $this->helper . '</Forms.Helper>'; $helperText = new Helper([
'id' => $helperId,
'slot' => $this->helper,
])->render();
$this->attributes['aria-describedby'] = $helperId; $this->attributes['aria-describedby'] = $helperId;
} }
$labelAttributes = [ $labelAttributes = [
'for' => $this->id, 'for' => $this->name,
'isOptional' => $this->required ? 'false' : 'true', 'isOptional' => $this->isRequired ? 'false' : 'true',
'class' => '-mb-1', 'class' => '-mb-1',
'slot' => $this->label,
]; ];
if ($this->hint) { if ($this->hint !== '') {
$labelAttributes['hint'] = $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 $this->mergeClass('flex flex-col');
$fieldComponentAttributes = $this->attributes; $fieldClass = $this->attributes['class'];
unset($fieldComponentAttributes['as']);
unset($fieldComponentAttributes['label']);
unset($fieldComponentAttributes['class']);
unset($fieldComponentAttributes['helper']);
unset($fieldComponentAttributes['hint']);
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; $element = __NAMESPACE__ . '\\' . $this->as;
$fieldElement = new $element($fieldComponentAttributes); $fieldElement = new $element($this->attributes);
return <<<HTML return <<<HTML
<div class="flex flex-col {$this->class}"> <div class="{$fieldClass}">
<Forms.Label {$labelAttributes}>{$this->label}</Forms.Label> {$label->render()}
{$helperText} {$helperText}
<div class="w-full mt-1"> <div class="relative w-full mt-1">
{$fieldElement->render()} {$fieldElement->render()}
</div> </div>
</div> </div>

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