├── LICENSE.md ├── README.md ├── composer.json ├── index.css ├── index.js ├── index.php └── src ├── classes ├── Changes.php ├── Instance.php ├── Instances.php ├── Plugin.php ├── Version.php └── Versions.php └── config ├── api.php ├── areas.php ├── hooks.php ├── i18n ├── de.php ├── en.php └── fr.php ├── options.php ├── permissions.php └── translations.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021 Lukas Bestle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Versions for Kirby 2 | 3 | [![Kirby 3.7.0+](https://img.shields.io/badge/Kirby-3.7.0%2B-green)](https://getkirby.com) 4 | [![MIT license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.md) 5 | [![Release](https://img.shields.io/github/v/release/lukasbestle/kirby-versions)](https://github.com/lukasbestle/kirby-versions/releases/latest) 6 | [![CI Status](https://img.shields.io/github/actions/workflow/status/lukasbestle/kirby-versions/ci.yml?branch=main&label=CI)](https://github.com/lukasbestle/kirby-versions/actions?query=workflow%3ACI+branch%3Amain) 7 | [![Coverage Status](https://img.shields.io/codecov/c/gh/lukasbestle/kirby-versions?token=IBYEIB22SM)](https://codecov.io/gh/lukasbestle/kirby-versions) 8 | 9 | > Keep track of content changes and switch between different versions from the Kirby Panel 10 | 11 | ![Screenshot of the Versions view in the Kirby Panel](screenshot.png) 12 | 13 | ## Support my work 14 | 15 | > The Versions plugin is completely free and published under the terms of the MIT license. I do not sell licenses or accept donations, but I'm available for contract work regarding feature development for this plugin. 16 | > ➯ [Read more…](.github/CONTRIBUTING.md#monetary-support) 17 | 18 | ## About this plugin 19 | 20 | The Versions plugin was built with three use cases in mind: 21 | 22 | 1. By version controlling the entire contents of your Kirby site, you can go back in time whenever something is changed by accident. You can also find out when the change was made and who made it for which reason. 23 | 2. The content and its history should be backed up externally in case the data center burns down or the server is attacked. This backup should always be consistent and atomic so an accurate restore can be guaranteed. 24 | 3. In a Kirby site with multiple instances (e.g. staging and production), it should be possible to deploy a new content version to another instance. This deployment should again be consistent. 25 | 26 | The [Git version control system](https://git-scm.com) is very well suited for all of these tasks as it is remarkably robust and provides a lot of useful features out of the box that help with all of these goals. This is why the Versions plugin is built on top of Git. 27 | 28 | This plugin is made for you if you have one or multiple of the use cases described above. What they have in common is that the content primarily "lives" on the server and is edited from the Kirby Panel. If you instead want to edit the content locally and push it to the server using Git, other plugins will be better suited for your use case. 29 | 30 | ## Features 31 | 32 | - Create and delete content versions directly from the Panel including metadata (time of creation, author, custom label) 33 | - Export versions as ZIP files for local backup 34 | - Switch between the versions on the fly 35 | - Support for multiple site instances (e.g. production and staging) that share their versions and that can be deployed to from a single Panel instance 36 | **Note:** This feature currently only works if all sites are hosted on the same server (i.e. if Kirby has access to the file system of all sites). 37 | - Support for fine-grained user permissions 38 | 39 | ## Requirements 40 | 41 | - Kirby 4.0.0+ (version 1.1.0 supports Kirby 3.7.0+) 42 | - PHP 8.1+ 43 | - Git 2.5+ (ideally newer for better reliability) 44 | 45 | Support for older Kirby versions is provided by previous versions of this plugin. I recommend to update your Kirby installation to benefit from fixes and improvements both in Kirby and in this plugin. 46 | 47 | ## Documentation 48 | 49 | The [plugin documentation](https://github.com/lukasbestle/kirby-versions/wiki) will show you how to set up the plugin initially, how to configure common and advanced features and how to use the plugin. 50 | 51 | ## License 52 | 53 | [The MIT License](LICENSE.md) 54 | 55 | ## Contributing & Monetary Support 56 | 57 | See [`CONTRIBUTING.md`](.github/CONTRIBUTING.md). 58 | 59 | ## Credits 60 | 61 | - Author and developer: [Lukas Bestle](https://lukasbestle.com) 62 | - Idea: [Sascha Lack](https://slstudio.de) 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lukasbestle/kirby-versions", 3 | "description": "Versions Plugin for Kirby", 4 | "license": "MIT", 5 | "type": "kirby-plugin", 6 | "version": "2.0.1", 7 | "authors": [ 8 | { 9 | "name": "Lukas Bestle", 10 | "email": "project-kirbyversions@lukasbestle.com" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=8.1.0 <8.4.0", 15 | "getkirby/cms": "^4.0", 16 | "getkirby/composer-installer": "^1.1" 17 | }, 18 | "minimum-stability": "RC", 19 | "autoload-dev": { 20 | "psr-4": { 21 | "LukasBestle\\": "tests/" 22 | } 23 | }, 24 | "config": { 25 | "allow-plugins": { 26 | "getkirby/composer-installer": true 27 | } 28 | }, 29 | "extra": { 30 | "installer-name": "versions", 31 | "kirby-cms-path": false 32 | }, 33 | "scripts": { 34 | "analyze": [ 35 | "@analyze:composer", 36 | "@analyze:psalm", 37 | "@analyze:phpcpd", 38 | "@analyze:phpmd" 39 | ], 40 | "analyze:composer": "composer validate --strict --no-check-version --no-check-all", 41 | "analyze:phpcpd": "phpcpd --fuzzy --exclude node_modules --exclude tests --exclude vendor .", 42 | "analyze:phpmd": "phpmd . ansi phpmd.xml.dist --exclude 'node_modules/*,tests/*,vendor/*'", 43 | "analyze:psalm": "psalm", 44 | "ci": [ 45 | "@fix", 46 | "@analyze", 47 | "@test" 48 | ], 49 | "fix": "php-cs-fixer fix", 50 | "test": "phpunit --stderr --coverage-html=tests/coverage" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | .lbvs-changes li{padding-left:1.2em;position:relative}.lbvs-changes li span{position:absolute;left:0;font-family:var(--font-mono);font-weight:700}.lbvs-changes li span[data-status="+"],.lbvs-changes li span[data-status=C]{color:var(--color-positive)}.lbvs-changes li span[data-status="-"]{color:var(--color-negative)}.lbvs-changes li span[data-status=M],.lbvs-changes li span[data-status=R]{color:var(--color-notice)}.lbvs-create-error-dialog{line-height:1.5}.lbvs-create-error-dialog-message{margin-bottom:1rem;color:var(--color-negative)}.lbvs-create-error-dialog-list li{margin-left:1.2rem;list-style:disc}.lbvs-create-error-dialog-list span{color:var(--color-text-light)}.lbvs-create-changes{margin-bottom:2.25rem}.lbvs-version-delete-dialog{line-height:1.5}.lbvs-instance-name{display:inline-block;padding:0 .3em;border-radius:var(--rounded);color:var(--color-text)}strong.lbvs-instance-name{padding:.2em .4em}.lbvs-instance-names-cell{line-height:1.5;padding:.325rem .75rem}.lbvs-instance-names-cell .lbvs-instance-name{margin-top:.1625rem;margin-bottom:.1625rem;margin-right:.325rem}.lbvs-status{padding-top:1.5rem;padding-bottom:2rem;background:#2b2b2b;color:var(--color-white);border-radius:var(--rounded)}.lbvs-status .k-grid{grid-row-gap:1.5rem}.lbvs-status-instances li{padding:.8rem}.lbvs-status-instances li.current{background:var(--color-background);border-radius:var(--rounded);color:var(--color-text)}.lbvs-status-current{display:inline-block;margin-left:.5rem;padding:.1em .3em;border:1px solid var(--color-border);border-radius:var(--rounded-sm);font-size:var(--font-size-small)}.lbvs-status-instances .lbvs-version{margin-top:.4rem}.lbvs-status-changes{display:flex;flex-direction:column;height:100%}.lbvs-status-changes .lbvs-changes{height:100%;padding:.8rem;background:var(--color-background);border-radius:var(--rounded);color:var(--color-text)}@media screen and (min-width: 30em){.lbvs-status-instances li.current{border-top-right-radius:0;border-bottom-right-radius:0}.lbvs-status-changes .lbvs-changes{border-top-left-radius:0;border-bottom-left-radius:0}}.lbvs-version{line-height:1.4}.lbvs-version-details{font-size:var(--font-size-small)}.lbvs-version-details dd{display:inline}.lbvs-version-details dd:not(:last-child):after{content:" · ";color:var(--color-text-light)}.lbvs-version-label-cell{padding:.325rem .75rem}.lbvs-versions{padding-top:1.5rem}.lbvs-version-dialog .lbvs-version{margin-bottom:1.5rem}.lbvs-view>.k-loader{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)} 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | (function(){"use strict";function i(t,e,s,n,r,c,u,xe){var a=typeof t=="function"?t.options:t;e&&(a.render=e,a.staticRenderFns=s,a._compiled=!0),n&&(a.functional=!0),c&&(a._scopeId="data-v-"+c);var l;if(u?(l=function(o){o=o||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,!o&&typeof __VUE_SSR_CONTEXT__<"u"&&(o=__VUE_SSR_CONTEXT__),r&&r.call(this,o),o&&o._registeredComponents&&o._registeredComponents.add(u)},a._ssrRegister=l):r&&(l=xe?function(){r.call(this,(a.functional?this.parent:this).$root.$options.shadowRoot)}:r),l)if(a.functional){a._injectStyles=l;var De=a.render;a.render=function(Se,d){return l.call(d),De(Se,d)}}else{var v=a.beforeCreate;a.beforeCreate=v?[].concat(v,l):[l]}return{exports:t,options:a}}const _={props:{changes:Object}};var f=function(){var e=this,s=e._self._c;return s("ul",{staticClass:"lbvs-changes"},e._l(e.changes,function(n,r){return s("li",{key:r},[s("span",{attrs:{"data-status":n,title:e.$t("versions.label.status."+n)}},[e._v(" "+e._s(n)+" ")]),e._v(" "+e._s(r)+" ")])}),0)},h=[],b=i(_,f,h,!1,null,null,null,null);const p=b.exports,m={data(){return{error:{message:null,details:{lockedModels:{}}}}},methods:{open(t){this.error=t,this.$refs.dialog.open()}}};var g=function(){var e=this,s=e._self._c;return s("k-dialog",{ref:"dialog",staticClass:"lbvs-create-error-dialog",attrs:{"cancel-button":e.$t("close"),"submit-button":!1}},[s("p",{staticClass:"lbvs-create-error-dialog-message"},[e._v(" "+e._s(e.error.message)+" ")]),s("ul",{staticClass:"lbvs-create-error-dialog-list"},e._l(e.error.details.lockedModels,function(n,r){return s("li",{key:r},[e._v(" "+e._s(r)+" "),s("span",[e._v("("+e._s(n.join(", "))+")")])])}),0)])},$=[],y=i(m,g,$,!1,null,null,null,null);const C=y.exports,k={data(){return{instance:null,inProgress:!1,stagedChanges:{}}},computed:{fields(){return{label:{autofocus:!0,icon:"title",label:this.$t("versions.label.label"),type:"text"}}}},methods:{async onSubmit(){if(this.inProgress!==!0)try{this.inProgress=!0;let t=this.$refs.form.value.label;if(!t)throw this.$t("field.required");await this.$store.dispatch({type:"versions/createVersion",instance:this.instance,label:t}),this.$store.dispatch("notification/success",":)"),this.$refs.dialog.close()}catch(t){this.$refs.dialog.error(t.message||t)}finally{this.inProgress=!1}},async open(t){this.instance=t;try{this.stagedChanges=await this.$store.dispatch({type:"versions/prepareVersionCreation",instance:this.instance})}catch(e){if(e.key==="error.versions.lockFiles")return this.$refs.errorDialog.open(e);throw e}this.$refs.dialog.open()}}};var w=function(){var e=this,s=e._self._c;return s("div",[s("k-dialog",{ref:"dialog",attrs:{size:"large","submit-button":e.$t("versions.button.create"),theme:"positive"},on:{submit:e.onSubmit}},[s("k-form",{ref:"form",attrs:{fields:e.fields},on:{submit:e.onSubmit},scopedSlots:e._u([{key:"header",fn:function(){return[s("k-field",{staticClass:"lbvs-create-changes",attrs:{label:e.$t("versions.label.changes")}},[s("lbvs-changes",{attrs:{changes:e.stagedChanges}})],1)]},proxy:!0}])})],1),s("lbvs-create-error-dialog",{ref:"errorDialog"})],1)},x=[],D=i(k,w,x,!1,null,null,null,null);const S=D.exports,R={data(){return{inProgress:!1,version:null}},methods:{async onSubmit(){if(this.inProgress!==!0)try{this.inProgress=!0,await this.$store.dispatch({type:"versions/deleteVersion",version:this.version.name}),this.$store.dispatch("notification/success",":)"),this.$refs.dialog.close()}catch(t){this.$refs.dialog.error(t.message||t)}finally{this.inProgress=!1}},open(t){this.version=t,this.$refs.dialog.open()}}};var T=function(){var e=this,s=e._self._c;return s("k-dialog",{ref:"dialog",staticClass:"lbvs-version-dialog lbvs-version-delete-dialog",attrs:{icon:"trash","submit-button":e.$t("versions.button.delete"),theme:"negative"},on:{submit:e.onSubmit}},[e.version?s("lbvs-version",{attrs:{version:e.version}}):e._e(),s("p",[e._v(e._s(e.$t("versions.message.delete")))])],1)},V=[],F=i(R,T,V,!1,null,null,null,null);const O=F.exports,A={data(){return{inProgress:!1,version:null}},computed:{fields(){let t=this.$store.getters["versions/currentInstance"],e=Object.values(this.$store.state.versions.data.instances).map(s=>({text:s.name,value:s.name}));return{instance:{disabled:e.length<=1,empty:!1,icon:"box",label:this.$t("versions.label.targetInstance"),options:e,placeholder:t.name,type:"select",value:t.name}}}},methods:{async onSubmit(){if(this.inProgress!==!0)try{this.inProgress=!0;let t=this.$refs.form.value.instance||this.$store.getters["versions/currentInstance"].name;await this.$store.dispatch({type:"versions/deployVersion",version:this.version.name,instance:t}),this.$store.dispatch("notification/success",":)"),this.$refs.dialog.close()}catch(t){this.$refs.dialog.error(t.message||t)}finally{this.inProgress=!1}},open(t){this.version=t,this.$refs.dialog.open()}}};var P=function(){var e=this,s=e._self._c;return s("k-dialog",{ref:"dialog",staticClass:"lbvs-version-dialog",attrs:{"submit-button":e.$t("versions.button.deploy"),theme:"positive"},on:{submit:e.onSubmit}},[e.version?s("lbvs-version",{attrs:{version:e.version}}):e._e(),s("k-form",{ref:"form",attrs:{fields:e.fields},on:{submit:e.onSubmit}})],1)},j=[],I=i(A,P,j,!1,null,null,null,null);const E=I.exports,L={data(){return{data:null,version:{}}},computed:{details(){return this.data?[{title:this.$t("versions.label.fileSize"),value:this.data.filesize}]:[]},supportsClipboard(){try{return window.navigator.clipboard.writeText,!0}catch{return!1}}},methods:{async copyToClipboard(){await window.navigator.clipboard.writeText(this.data.url),this.$store.dispatch("notification/success",":)")},download(){window.location=this.data.url,this.$store.dispatch("notification/success",":)")},async open(t){this.data=null,this.version=t,this.$refs.dialog.open();let e=await this.$store.dispatch({type:"versions/exportVersion",version:this.version.name});t===this.version&&(this.data=e)}}};var N=function(){var e=this,s=e._self._c;return s("k-dialog",{ref:"dialog",staticClass:"lbvs-version-dialog",attrs:{"cancel-button":e.$t(e.data?"close":"cancel"),"submit-button":!1}},[e.version?s("lbvs-version",{attrs:{details:e.details,version:e.version}}):e._e(),e.data?s("k-button-group",[s("k-button",{attrs:{icon:"download"},on:{click:e.download}},[e._v(" "+e._s(e.$t("versions.button.download"))+" ")]),s("k-button",{attrs:{icon:"copy",disabled:!e.supportsClipboard},on:{click:e.copyToClipboard}},[e._v(" "+e._s(e.$t("versions.button.copyLink"))+" ")])],1):s("p",[e._v(" "+e._s(e.$t("versions.message.exporting"))+" ")])],1)},z=[],M=i(L,N,z,!1,null,null,null,null);const Y=M.exports,B={props:{inline:Boolean,instance:[Object,String]},computed:{element(){return this.inline?"span":"strong"},instanceObj(){return typeof this.instance=="string"?{name:this.instance,color:"var(--color-gray-300)"}:this.instance}}};var H=function(){var e=this,s=e._self._c;return s(e.element,{tag:"component",staticClass:"lbvs-instance-name",style:{backgroundColor:e.instanceObj.color}},[e._v(" "+e._s(e.instanceObj.name)+" ")])},U=[],X=i(B,H,U,!1,null,null,null,null);const q=X.exports,W={props:{value:[Array,String]},computed:{instances(){return Array.isArray(this.value)?this.value:[this.value]}}};var G=function(){var e=this,s=e._self._c;return s("div",{staticClass:"lbvs-instance-names-cell"},e._l(e.instances,function(n){return s("lbvs-instance-name",{key:n,attrs:{inline:!0,instance:e.$store.state.versions.data.instances[n]||n}})}),1)},J=[],K=i(W,G,J,!1,null,null,null,null);const Q=K.exports,Z={computed:{canCreateVersion(){return this.$permissions["lukasbestle.versions"].create===!0&&Object.keys(this.currentChanges).length>0},currentChanges(){return this.$store.getters["versions/currentInstance"].changes}},methods:{onCreate(){let t=this.$store.getters["versions/currentInstance"].name;return this.$refs.createDialog.open(t)}}};var ee=function(){var e=this,s=e._self._c;return s("div",{staticClass:"lbvs-status"},[s("k-view",[s("k-grid",[s("k-column",{attrs:{width:"1/3"}},[s("header",{staticClass:"k-section-header"},[s("k-label",{attrs:{type:"section"}},[e._v(" "+e._s(e.$t("versions.label.instances"))+" ")])],1),s("ul",{staticClass:"lbvs-status-instances"},e._l(e.$store.state.versions.data.instances,function(n){return s("li",{key:n.name,class:{current:n.isCurrent}},[s("lbvs-instance-name",{attrs:{instance:n}}),n.isCurrent?s("span",{staticClass:"lbvs-status-current"},[e._v(" "+e._s(e.$t("versions.label.current"))+" ")]):e._e(),n.version?s("lbvs-version",{attrs:{version:{name:n.version,label:n.versionLabel}}}):e._e()],1)}),0)]),s("k-column",{staticClass:"lbvs-status-changes",attrs:{width:"2/3"}},[s("header",{staticClass:"k-section-header"},[s("k-label",{attrs:{type:"section"}},[e._v(" "+e._s(e.$t("versions.label.changes"))+" ")]),s("k-button",{attrs:{icon:"add",size:"xs",disabled:e.canCreateVersion===!1},on:{click:e.onCreate}},[e._v(" "+e._s(e.$t("versions.button.create"))+" ")])],1),s("lbvs-changes",{attrs:{changes:e.currentChanges}})],1)],1)],1),s("lbvs-create-dialog",{ref:"createDialog"})],1)},se=[],te=i(Z,ee,se,!1,null,null,null,null);const ne=te.exports,re={props:{details:{type:Array,default(){return[]}},instances:Boolean,version:Object},computed:{mergedDetails(){return[{title:this.$t("versions.label.versionName"),value:this.version.name},...this.details].filter(e=>e.value)}}};var ae=function(){var e=this,s=e._self._c;return s("div",{staticClass:"lbvs-version"},[s("strong",[e._v(e._s(e.version.label))]),s("dl",{staticClass:"lbvs-version-details"},[e._l(e.mergedDetails,function(n){return[s("dt",{key:n.title,staticClass:"sr-only"},[e._v(e._s(n.title)+":")]),s("dd",{key:n.title,attrs:{title:n.title}},[e._v(" "+e._s(n.value)+" ")])]})],2)])},ie=[],oe=i(re,ae,ie,!1,null,null,null,null);const le=oe.exports,ce={props:{value:Object}};var ue=function(){var e=this,s=e._self._c;return s("div",{staticClass:"lbvs-version-label-cell"},[s("lbvs-version",{attrs:{version:e.value}})],1)},ve=[],de=i(ce,ue,ve,!1,null,null,null,null);const _e=de.exports,fe={computed:{columns(){return{title:{label:this.$t("versions.label.label"),type:"lbvs-version-label",mobile:!0,width:"35%"},instances:{label:this.$t("versions.label.instances"),type:"lbvs-instance-names",mobile:!0,width:"25%"},creation:{label:this.$t("versions.label.creation"),type:"text",width:"25%"},originInstance:{label:this.$t("versions.label.originInstance"),type:"lbvs-instance-names",width:"15%"}}},items(){return Object.values(this.$store.state.versions.data.versions).map(t=>(t.creation=this.$t("versions.label.creationData",{created:t.created?this.$library.dayjs.unix(t.created).format("YYYY-MM-DD HH:mm"):"?",creator:t.creatorName||t.creatorEmail||"?"}),t.title={label:t.label,name:t.name},t.options=this.options(t),t))}},methods:{onOption(t,e){return this.$refs[t+"Dialog"].open(e)},options(t){let e=this.$permissions["lukasbestle.versions"];return[{click:"export",disabled:e.export!==!0,icon:"download",text:this.$t("versions.button.export")},{click:"deploy",disabled:e.deploy!==!0,icon:"wand",text:this.$t("versions.button.deploy")},{click:"delete",disabled:e.delete!==!0||t.instances.length>0,icon:"trash",text:this.$t("versions.button.delete")}]}}};var he=function(){var e=this,s=e._self._c;return s("div",{staticClass:"lbvs-versions"},[s("header",{staticClass:"k-section-header"},[s("k-label",{attrs:{type:"section"}},[e._v(" "+e._s(e.$t("versions.label.versions"))+" ")])],1),e.items.length?s("k-items",{attrs:{columns:e.columns,items:e.items,layout:"table",sortable:!1},on:{option:e.onOption}}):s("k-empty",{attrs:{icon:"protected",layout:"table"}},[e._v(" "+e._s(e.$t("versions.label.empty"))+" ")]),s("lbvs-export-dialog",{ref:"exportDialog"}),s("lbvs-deploy-dialog",{ref:"deployDialog"}),s("lbvs-delete-dialog",{ref:"deleteDialog"})],1)},be=[],pe=i(fe,he,be,!1,null,null,null,null);const me=pe.exports,ge={data(){return{isLoading:!0}},async mounted(){this.$permissions["lukasbestle.versions"].access!==!0&&this.$go("/");try{this.isLoading=!0,await this.$store.dispatch("versions/load")}finally{this.isLoading=!1}}};var $e=function(){var e=this,s=e._self._c;return s("k-inside",[s("div",{staticClass:"lbvs-view"},[e.isLoading?s("k-loader"):[s("lbvs-status"),s("lbvs-versions")]],2)])},ye=[],Ce=i(ge,$e,ye,!1,null,null,null,null);const ke=Ce.exports,we=t=>({namespaced:!0,state:{data:{instances:{},versions:{}}},getters:{currentInstance(e){return Object.values(e.data.instances).find(s=>s.isCurrent)}},mutations:{SET_DATA(e,{instances:s,versions:n}){e.data.instances=s,e.data.versions=n}},actions:{async load({commit:e}){e("SET_DATA",await t.$api.get("versions"))},async prepareVersionCreation(e,{instance:s}){return await t.$api.post("versions/prepareCreation",{instance:s})},async createVersion({commit:e},{instance:s,label:n}){let r=await t.$api.post("versions/create",{instance:s,label:n});e("SET_DATA",r)},async deleteVersion({commit:e},{version:s}){let n=await t.$api.delete("versions/versions/"+s);e("SET_DATA",n)},async deployVersion({commit:e},{instance:s,version:n}){let r=await t.$api.post("versions/versions/"+n+"/deploy",{instance:s});e("SET_DATA",r)},async exportVersion(e,{version:s}){return await t.$api.post("versions/versions/"+s+"/export")}}});panel.plugin("lukasbestle/versions",{components:{"k-table-lbvs-instance-names-cell":Q,"k-table-lbvs-version-label-cell":_e,"lbvs-changes":p,"lbvs-create-error-dialog":C,"lbvs-create-dialog":S,"lbvs-delete-dialog":O,"lbvs-deploy-dialog":E,"lbvs-export-dialog":Y,"lbvs-instance-name":q,"lbvs-status":ne,"lbvs-version":le,"lbvs-versions":me,"lbvs-view":ke},created(t){t.$store.registerModule("versions",we(t))}})})(); 2 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | 14 | * @link https://github.com/lukasbestle/kirby-versions 15 | * @copyright Lukas Bestle 16 | * @license https://opensource.org/licenses/MIT 17 | */ 18 | 19 | // validate the Kirby version; the supported versions are 20 | // updated manually when verified to work with the plugin 21 | $kirbyVersion = App::version(); 22 | if ( 23 | $kirbyVersion !== null && 24 | ( 25 | version_compare($kirbyVersion, '4.0.0-rc.1', '<') === true || 26 | version_compare($kirbyVersion, '5.0.0-alpha', '>=') === true 27 | ) 28 | ) { 29 | throw new Exception( 30 | 'The installed version of the Kirby Versions plugin ' . 31 | 'is not compatible with Kirby ' . $kirbyVersion 32 | ); 33 | } 34 | 35 | // autoload classes 36 | F::loadClasses([ 37 | 'LukasBestle\Versions\Changes' => __DIR__ . '/src/classes/Changes.php', 38 | 'LukasBestle\Versions\Instance' => __DIR__ . '/src/classes/Instance.php', 39 | 'LukasBestle\Versions\Instances' => __DIR__ . '/src/classes/Instances.php', 40 | 'LukasBestle\Versions\Plugin' => __DIR__ . '/src/classes/Plugin.php', 41 | 'LukasBestle\Versions\Version' => __DIR__ . '/src/classes/Version.php', 42 | 'LukasBestle\Versions\Versions' => __DIR__ . '/src/classes/Versions.php' 43 | ]); 44 | 45 | // register the plugin 46 | App::plugin('lukasbestle/versions', [ 47 | 'api' => require __DIR__ . '/src/config/api.php', 48 | 'areas' => require __DIR__ . '/src/config/areas.php', 49 | 'hooks' => require __DIR__ . '/src/config/hooks.php', 50 | 'options' => require __DIR__ . '/src/config/options.php', 51 | 'permissions' => require __DIR__ . '/src/config/permissions.php', 52 | 'translations' => require __DIR__ . '/src/config/translations.php' 53 | ]); 54 | -------------------------------------------------------------------------------- /src/classes/Changes.php: -------------------------------------------------------------------------------- 1 | 13 | * @link https://github.com/lukasbestle/kirby-versions 14 | * @copyright Lukas Bestle 15 | * @license https://opensource.org/licenses/MIT 16 | */ 17 | class Changes 18 | { 19 | /** 20 | * Cache for changes in the index 21 | * 22 | * @var array 23 | */ 24 | protected $inIndex; 25 | 26 | /** 27 | * Configured instance to get changes for 28 | * 29 | * @var \LukasBestle\Versions\Instance 30 | */ 31 | protected $instance; 32 | 33 | /** 34 | * Cache for changes in the worktree 35 | * 36 | * @var array 37 | */ 38 | protected $inWorktree; 39 | 40 | /** 41 | * Cache for changed Kirby lock files 42 | * 43 | * @var array 44 | */ 45 | protected $lockFiles; 46 | 47 | /** 48 | * Instance of the Plugin class 49 | * 50 | * @var \LukasBestle\Versions\Plugin 51 | */ 52 | protected $plugin; 53 | 54 | /** 55 | * Class constructor 56 | * 57 | * @param \LukasBestle\Versions\Plugin $plugin 58 | * @param \LukasBestle\Versions\Instance $instance 59 | */ 60 | public function __construct(Plugin $plugin, Instance $instance) 61 | { 62 | $this->plugin = $plugin; 63 | $this->instance = $instance; 64 | } 65 | 66 | /** 67 | * Returns the changes in the index 68 | * 69 | * @return array 70 | * 71 | * @throws \Kirby\Exception\Exception If there is a Git error 72 | */ 73 | public function inIndex(): array 74 | { 75 | $this->initialize(); 76 | 77 | return $this->inIndex; 78 | } 79 | 80 | /** 81 | * Returns the changes in the worktree 82 | * 83 | * @return array 84 | * 85 | * @throws \Kirby\Exception\Exception If there is a Git error 86 | */ 87 | public function inWorktree(): array 88 | { 89 | $this->initialize(); 90 | 91 | return $this->inWorktree; 92 | } 93 | 94 | /** 95 | * Returns the changed Kirby lock files 96 | * 97 | * @return array 98 | * 99 | * @throws \Kirby\Exception\Exception If there is a Git error 100 | */ 101 | public function lockFiles(): array 102 | { 103 | $this->initialize(); 104 | 105 | return $this->lockFiles; 106 | } 107 | 108 | /** 109 | * Returns the overall changes 110 | * 111 | * @return array 112 | * 113 | * @throws \Kirby\Exception\Exception If there is a Git error 114 | */ 115 | public function overall(): array 116 | { 117 | $this->initialize(); 118 | 119 | // let the worktree override the index because 120 | // the worktree information is more recent 121 | $merged = array_merge($this->inIndex, $this->inWorktree); 122 | 123 | // double-check for edge-cases 124 | foreach (array_keys($merged) as $file) { 125 | if ( 126 | isset($this->inIndex[$file]) === true && 127 | isset($this->inWorktree[$file]) === true 128 | ) { 129 | $index = $this->inIndex[$file]; 130 | $worktree = $this->inWorktree[$file]; 131 | 132 | // if the file was added but since modified, 133 | // it was overall still added 134 | if ($index === '+' && $worktree === 'M') { 135 | $merged[$file] = '+'; 136 | } 137 | 138 | // if the file was added and deleted, 139 | // nothing has changed 140 | if ($index === '+' && $worktree === '-') { 141 | unset($merged[$file]); 142 | } 143 | } 144 | } 145 | 146 | ksort($merged); 147 | return $merged; 148 | } 149 | 150 | /** 151 | * Loads the current changes from the worktree 152 | * 153 | * @return void 154 | * 155 | * @throws \Kirby\Exception\Exception If there is a Git error 156 | */ 157 | public function update(): void 158 | { 159 | // clear the current cache 160 | $this->inIndex = []; 161 | $this->lockFiles = []; 162 | $this->inWorktree = []; 163 | 164 | // collect all changed files 165 | $changes = $this->plugin->gitCommand($this->instance, 'status', '--porcelain=1'); 166 | 167 | // return early if there are no changes 168 | if ($changes === '') { 169 | return; 170 | } 171 | 172 | foreach (explode("\n", $changes) as $change) { 173 | $index = static::normalizeStatus(substr($change, 0, 1), 'index'); 174 | $worktree = static::normalizeStatus(substr($change, 1, 1), 'worktree'); 175 | $file = substr($change, 3); 176 | 177 | // collect Kirby lock files separately 178 | if ($file === '.lock' || Str::endsWith($file, '/.lock') === true) { 179 | $this->lockFiles[] = $file; 180 | continue; 181 | } 182 | 183 | if ($index !== null) { 184 | $this->inIndex[$file] = $index; 185 | } 186 | 187 | if ($worktree !== null) { 188 | $this->inWorktree[$file] = $worktree; 189 | } 190 | } 191 | 192 | // sort the arrays by file path 193 | ksort($this->inIndex); 194 | ksort($this->inWorktree); 195 | sort($this->lockFiles); 196 | } 197 | 198 | /** 199 | * Loads the current changes if this instance 200 | * is still in its initial state 201 | * 202 | * @return void 203 | */ 204 | protected function initialize(): void 205 | { 206 | if ( 207 | $this->inIndex === null || 208 | $this->inWorktree === null || 209 | $this->lockFiles === null 210 | ) { 211 | $this->update(); 212 | } 213 | } 214 | 215 | /** 216 | * Normalizes Git status characters into a human-readable form 217 | * 218 | * @param string $status A single character Git status 219 | * @param string $mode `index` or `worktree` 220 | * @return string|null A single human-readable character or `null` for unmodified 221 | */ 222 | protected static function normalizeStatus(string $status, string $mode): ?string 223 | { 224 | // unmodified 225 | if ($status === ' ') { 226 | return null; 227 | } 228 | 229 | // added 230 | if ($status === 'A') { 231 | return '+'; 232 | } 233 | 234 | // unknown (= added to worktree, but not to index) 235 | if ($status === '?') { 236 | return ($mode === 'worktree')? '+' : null; 237 | } 238 | 239 | // deleted 240 | if ($status === 'D') { 241 | return '-'; 242 | } 243 | 244 | // keep all other values 245 | return $status; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/classes/Instance.php: -------------------------------------------------------------------------------- 1 | 17 | * @link https://github.com/lukasbestle/kirby-versions 18 | * @copyright Lukas Bestle 19 | * @license https://opensource.org/licenses/MIT 20 | */ 21 | class Instance 22 | { 23 | use Properties; 24 | 25 | /** 26 | * Changes object for this instance 27 | * 28 | * @var \LukasBestle\Versions\Changes|null 29 | */ 30 | protected $changes; 31 | 32 | /** 33 | * CSS color for display in the Panel 34 | * 35 | * @var string 36 | */ 37 | protected $color; 38 | 39 | /** 40 | * Path to the content directory 41 | * 42 | * @var string 43 | */ 44 | protected $contentRoot; 45 | 46 | /** 47 | * Current commit of this instance 48 | * 49 | * @var string|null 50 | */ 51 | protected $currentCommit = null; 52 | 53 | /** 54 | * Instance name that is displayed in the Panel 55 | * 56 | * @var string 57 | */ 58 | protected $name; 59 | 60 | /** 61 | * Instance of the Plugin class 62 | * 63 | * @var \LukasBestle\Versions\Plugin 64 | */ 65 | protected $plugin; 66 | 67 | /** 68 | * Class constructor 69 | * 70 | * @param array $props 71 | * 72 | * @throws \Kirby\Exception\Exception If the configuration is invalid 73 | */ 74 | public function __construct(array $props) 75 | { 76 | $this->setProperties($props); 77 | 78 | // validate that the instance has a Git repo set up 79 | try { 80 | /** @var \LukasBestle\Versions\Plugin $this->plugin */ 81 | if ($this->plugin->gitCommand($this, 'rev-parse', '--is-inside-work-tree') !== 'true') { 82 | throw new Exception(); // @codeCoverageIgnore 83 | } 84 | } catch (Exception $e) { 85 | throw new Exception([ 86 | 'key' => 'versions.instance.noRepo', 87 | 'data' => ['instance' => $this->name()], 88 | 'previous' => $e 89 | ]); 90 | } 91 | } 92 | 93 | /** 94 | * Returns the Changes object for this instance 95 | * 96 | * @return \LukasBestle\Versions\Changes 97 | */ 98 | public function changes(): Changes 99 | { 100 | if ($this->changes !== null) { 101 | return $this->changes; 102 | } 103 | 104 | return $this->changes = new Changes($this->plugin, $this); 105 | } 106 | 107 | /** 108 | * Returns the CSS color for display in the Panel 109 | * 110 | * @return string 111 | */ 112 | public function color(): string 113 | { 114 | return $this->color; 115 | } 116 | 117 | /** 118 | * Returns the path to the content directory 119 | * 120 | * @return string 121 | */ 122 | public function contentRoot(): string 123 | { 124 | return $this->contentRoot; 125 | } 126 | 127 | /** 128 | * Creates a new version based on the already 129 | * prepared changes with `prepareCreation()` 130 | * 131 | * @param string $label Custom version label 132 | * @return \LukasBestle\Versions\Version 133 | * 134 | * @throws \Kirby\Exception\Exception If there is a Git error 135 | * @throws \Kirby\Exception\LogicException If there are no staged files 136 | */ 137 | public function createVersion(string $label): Version 138 | { 139 | // we can only create a version if there are staged changes 140 | if ($this->changes()->inIndex() === []) { 141 | throw new LogicException([ 142 | 'key' => 'versions.notPrepared' 143 | ]); 144 | } 145 | 146 | // collect user data 147 | $user = $this->plugin->kirby()->user(); 148 | if ($user !== null) { 149 | $userEmail = $user->email(); 150 | 151 | /** @var string $userName */ 152 | $userName = $user->name()->or($userEmail)->value(); 153 | } else { 154 | $userEmail = 'versions@' . $this->plugin->kirby()->request()->url()->domain(); 155 | 156 | /** @var string $userName */ 157 | $userName = I18n::translate('view.versions'); 158 | } 159 | 160 | // auto-generate a unique name 161 | $date = date('Ymd'); 162 | $nextNumber = count($this->plugin->versions()->filterBy('name', '^=', $date . '_')) + 1; 163 | do { 164 | $name = $date . '_' . str_pad((string)($nextNumber), 3, '0', STR_PAD_LEFT); 165 | $nextNumber++; 166 | } while ($this->plugin->versions()->has($name) === true); 167 | 168 | // build the label 169 | $label = $this->name() . ':::' . $label; 170 | 171 | // create a commit and tag with the identity of the current user 172 | $this->plugin->gitCommand($this, '-c', 'user.name=' . $userName, '-c', 'user.email=' . $userEmail, 'commit', '-m', $label); 173 | $this->plugin->gitCommand($this, '-c', 'user.name=' . $userName, '-c', 'user.email=' . $userEmail, 'tag', $name, '-a', '-m', $label); 174 | 175 | // update the Versions collection 176 | $this->plugin->versions()->update(); 177 | $version = $this->plugin->versions()->get($name); 178 | 179 | // update cache 180 | $this->setCurrentCommit($version->commit()); 181 | $this->changes()->update(); 182 | 183 | return $version; 184 | } 185 | 186 | /** 187 | * Returns the current commit of this instance 188 | * 189 | * @return string|null 190 | */ 191 | public function currentCommit(): ?string 192 | { 193 | return $this->currentCommit; 194 | } 195 | 196 | /** 197 | * Returns whether the instance is the one of the current site 198 | * 199 | * @return bool 200 | */ 201 | public function isCurrent(): bool 202 | { 203 | return $this->contentRoot() === $this->plugin->kirby()->roots()->content(); 204 | } 205 | 206 | /** 207 | * Returns the instance name that is displayed in the Panel 208 | * 209 | * @return string 210 | */ 211 | public function name(): string 212 | { 213 | return $this->name; 214 | } 215 | 216 | /** 217 | * Prepares version creation by staging all 218 | * changes and checking if there are any lock files 219 | * 220 | * @return void 221 | * 222 | * @throws \Kirby\Exception\Exception If there is a Git error 223 | * @throws \Kirby\Exception\LogicException If there are no changed files 224 | * @throws \Kirby\Exception\LogicException If there are Kirby lock files 225 | */ 226 | public function prepareCreation(): void 227 | { 228 | // only do something if there are changes at all 229 | if ($this->changes()->overall() === []) { 230 | throw new LogicException([ 231 | 'key' => 'versions.noChanges' 232 | ]); 233 | } 234 | 235 | // first stage everything to ensure that files in 236 | // untracked directories are listed in the changes 237 | $this->plugin->gitCommand($this, 'add', '-A'); 238 | $this->changes()->update(); 239 | 240 | // now ensure that there no Kirby models are locked 241 | $lockFiles = $this->changes()->lockFiles(); 242 | if ($lockFiles !== []) { 243 | $lockedModels = []; 244 | 245 | // parse each lock file and figure out what is locked by whom 246 | foreach ($lockFiles as $lockFile) { 247 | /** @var array 250 | * }> $data 251 | */ 252 | $data = Data::read($this->contentRoot() . '/' . $lockFile, 'yaml'); 253 | 254 | foreach ($data as $modelId => $model) { 255 | $users = []; 256 | 257 | if (isset($model['lock']['user']) === true) { 258 | $users[] = $model['lock']['user']; 259 | } 260 | 261 | if (isset($model['unlock']) === true) { 262 | $users = array_merge($users, $model['unlock']); 263 | } 264 | 265 | $lockedModels[$modelId] = array_map(function (string $user) { 266 | $userObject = $this->plugin->kirby()->user($user); 267 | 268 | if ($userObject !== null) { 269 | return $userObject->name()->or($userObject->email())->value(); 270 | } 271 | 272 | return $user; 273 | }, array_values(array_unique($users))); 274 | } 275 | } 276 | 277 | if (empty($lockedModels) !== true) { 278 | // unstage everything again 279 | $this->plugin->gitCommand($this, 'reset'); 280 | $this->changes()->update(); 281 | 282 | throw new LogicException([ 283 | 'key' => 'versions.lockFiles', 284 | 'details' => compact('lockedModels') 285 | ]); 286 | } 287 | } 288 | 289 | // ensure that lock files are never staged, even if empty 290 | $this->plugin->gitCommand($this, 'reset', '--', '.lock', '**/.lock'); 291 | $this->changes()->update(); 292 | } 293 | 294 | /** 295 | * Sets the current commit of this instance 296 | * @internal 297 | * 298 | * @param string|null $currentCommit 299 | * @return self 300 | */ 301 | public function setCurrentCommit(?string $currentCommit = null): self 302 | { 303 | $this->currentCommit = $currentCommit; 304 | return $this; 305 | } 306 | 307 | /** 308 | * Returns the instance data as array 309 | * 310 | * @return array 311 | * 312 | * @throws \Kirby\Exception\Exception If there is a Git error 313 | */ 314 | public function toArray(): array 315 | { 316 | $array = $this->propertiesToArray(); 317 | $array['changes'] = $this->changes()->overall(); 318 | $array['isCurrent'] = $this->isCurrent(); 319 | 320 | $version = $this->version(); 321 | $array['version'] = ($version !== null)? $version->name() : null; 322 | $array['versionLabel'] = ($version !== null)? $version->label() : null; 323 | 324 | ksort($array); 325 | return $array; 326 | } 327 | 328 | /** 329 | * Returns the current version of this instance 330 | * 331 | * @return \LukasBestle\Versions\Version|null 332 | */ 333 | public function version(): ?Version 334 | { 335 | if ($this->currentCommit === null) { 336 | return null; 337 | } 338 | 339 | return $this->plugin->versions()->findBy('commit', $this->currentCommit()); 340 | } 341 | 342 | /** 343 | * Sets the CSS color for display in the Panel 344 | * 345 | * @param string $color 346 | * @return self 347 | */ 348 | protected function setColor(string $color): self 349 | { 350 | $this->color = $color; 351 | return $this; 352 | } 353 | 354 | /** 355 | * Sets the path to the content directory 356 | * 357 | * @param string $contentRoot 358 | * @return self 359 | */ 360 | protected function setContentRoot(string $contentRoot): self 361 | { 362 | $this->contentRoot = rtrim($contentRoot, '/\\'); 363 | return $this; 364 | } 365 | 366 | /** 367 | * Sets the instance name that is displayed in the Panel 368 | * 369 | * @param string $name 370 | * @return self 371 | */ 372 | protected function setName(string $name): self 373 | { 374 | $this->name = $name; 375 | return $this; 376 | } 377 | 378 | /** 379 | * Sets the instance of the Plugin class 380 | * 381 | * @param \LukasBestle\Versions\Plugin $plugin 382 | * @return self 383 | */ 384 | protected function setPlugin(Plugin $plugin): self 385 | { 386 | $this->plugin = $plugin; 387 | return $this; 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /src/classes/Instances.php: -------------------------------------------------------------------------------- 1 | 17 | * @link https://github.com/lukasbestle/kirby-versions 18 | * @copyright Lukas Bestle 19 | * @license https://opensource.org/licenses/MIT 20 | * 21 | * @method \LukasBestle\Versions\Instance get($key, $default = null) 22 | */ 23 | class Instances extends Collection 24 | { 25 | /** 26 | * Instance of the Plugin class 27 | * 28 | * @var \LukasBestle\Versions\Plugin 29 | */ 30 | protected $plugin; 31 | 32 | /** 33 | * Class constructor 34 | * 35 | * @param \LukasBestle\Versions\Plugin $plugin 36 | * 37 | * @throws \Kirby\Exception\Exception If there is a Git error 38 | * @throws \Kirby\Exception\Exception If the configuration is invalid 39 | */ 40 | public function __construct(Plugin $plugin) 41 | { 42 | $this->plugin = $plugin; 43 | 44 | $currentContentRoot = $plugin->kirby()->roots()->content(); 45 | $config = $plugin->option('instances'); 46 | $instances = []; 47 | $initializeLocalSite = true; 48 | 49 | // initialize the configured instances if set 50 | if (is_array($config) === true) { 51 | foreach ($config as $name => $props) { 52 | $props['name'] = $name; 53 | $props['plugin'] = $plugin; 54 | $instances[$name] = new Instance($props); 55 | 56 | // the local site doesn't need to be initialized if already configured 57 | if ($props['contentRoot'] === $currentContentRoot) { 58 | $initializeLocalSite = false; 59 | } 60 | } 61 | } 62 | 63 | // prepend the local site if not already configured 64 | if ($initializeLocalSite === true) { 65 | /** @var string $name */ 66 | $name = I18n::translate('versions.name.local'); 67 | $instances = [$name => new Instance([ 68 | 'contentRoot' => $currentContentRoot, 69 | 'color' => 'var(--color-focus-light)', 70 | 'name' => $name, 71 | 'plugin' => $plugin 72 | ])] + $instances; 73 | } 74 | 75 | // set the instances in the collection and enable case-sensitive mode 76 | parent::__construct($instances, true); 77 | 78 | // check the initialized instances for configuration issues 79 | $this->validate(); 80 | } 81 | 82 | /** 83 | * Returns the specified instance or throws an 84 | * Exception if not found 85 | * 86 | * @param string $name 87 | * @return \LukasBestle\Versions\Instance 88 | * 89 | * @throws \Kirby\Exception\NotFoundException If the instance was not found 90 | */ 91 | public function findOrException(string $name): Instance 92 | { 93 | $instance = $this->find($name); 94 | if (!$instance) { 95 | throw new NotFoundException([ 96 | 'key' => 'versions.notFound.instance', 97 | 'data' => ['name' => $name] 98 | ]); 99 | } 100 | 101 | return $instance; 102 | } 103 | 104 | /** 105 | * Validates that all configured instances are connected 106 | * worktrees and have a detached head; the current commit 107 | * of each instance is set in the process 108 | * 109 | * @return void 110 | * 111 | * @throws \Kirby\Exception\Exception If there is a Git error 112 | * @throws \Kirby\Exception\Exception If the configuration is invalid 113 | */ 114 | protected function validate(): void 115 | { 116 | // determine the Git worktrees for validation 117 | $worktreesRaw = $this->plugin->gitCommand(null, 'worktree', 'list', '--porcelain'); 118 | $worktrees = []; 119 | foreach (explode("\n\n", trim($worktreesRaw)) as $worktree) { 120 | $attributesRaw = explode("\n", $worktree); 121 | $attributes = []; 122 | $path = null; 123 | 124 | foreach ($attributesRaw as $attribute) { 125 | if (Str::contains($attribute, ' ')) { 126 | $label = Str::before($attribute, ' '); 127 | $value = Str::after($attribute, ' '); 128 | } else { 129 | $label = $attribute; 130 | $value = true; 131 | } 132 | 133 | 134 | if ($label === 'worktree') { 135 | $path = $value; 136 | } else { 137 | $attributes[$label] = $value; 138 | } 139 | } 140 | 141 | // if no line has the label "worktree", the output 142 | // of this worktree is unexpected and we cannot continue 143 | if (is_string($path) !== true) { 144 | throw new Exception([ 145 | 'key' => 'versions.internal', 146 | 'data' => ['code' => 'git-worktree-invalid'] 147 | ]); 148 | } 149 | 150 | $worktrees[$path] = $attributes; 151 | } 152 | 153 | // validate each instance against the worktree setup 154 | foreach ($this as $instance) { 155 | /** @var \LukasBestle\Versions\Instance $instance */ 156 | 157 | // the configured instance needs to be a connected worktree 158 | $contentRoot = $instance->contentRoot(); 159 | if (isset($worktrees[$contentRoot]) !== true) { 160 | throw new Exception([ 161 | 'key' => 'versions.instance.noWorktree', 162 | 'data' => ['instance' => $instance->name()] 163 | ]); 164 | } 165 | 166 | // the instance must have a detached head 167 | $worktree = $worktrees[$contentRoot]; 168 | if (isset($worktree['detached']) !== true) { 169 | throw new Exception([ 170 | 'key' => 'versions.instance.onBranch', 171 | 'data' => ['instance' => $instance->name()] 172 | ]); 173 | } 174 | 175 | /** @var string $commit */ 176 | $commit = $worktree['HEAD']; 177 | 178 | // set the current commit for later use 179 | $instance->setCurrentCommit($commit); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/classes/Plugin.php: -------------------------------------------------------------------------------- 1 | 17 | * @link https://github.com/lukasbestle/kirby-versions 18 | * @copyright Lukas Bestle 19 | * @license https://opensource.org/licenses/MIT 20 | */ 21 | class Plugin 22 | { 23 | /** 24 | * Singleton plugin instance 25 | * 26 | * @var self|null 27 | */ 28 | protected static $instance; 29 | 30 | /** 31 | * Collection of configured site instances 32 | * 33 | * @var \LukasBestle\Versions\Instances|null 34 | */ 35 | protected $instances; 36 | 37 | /** 38 | * Kirby App instance 39 | * 40 | * @var \Kirby\Cms\App 41 | */ 42 | protected $kirby; 43 | 44 | /** 45 | * Whether the environment has already been validated 46 | * 47 | * @var bool 48 | */ 49 | protected $validated = false; 50 | 51 | /** 52 | * Collection of the existing versions 53 | * 54 | * @var \LukasBestle\Versions\Versions|null 55 | */ 56 | protected $versions; 57 | 58 | /** 59 | * Class constructor 60 | * 61 | * @param \Kirby\Cms\App|null $kirby Kirby App instance to use (optional) 62 | */ 63 | public function __construct(?App $kirby = null) 64 | { 65 | /** @psalm-suppress PossiblyNullPropertyAssignmentValue */ 66 | $this->kirby = $kirby ?? App::instance(); 67 | } 68 | 69 | /** 70 | * Ensures that the current user has the specified permission 71 | * 72 | * @param string $permission 73 | * @return void 74 | * 75 | * @throws \Kirby\Exception\LogicException If no user is logged in 76 | * @throws \Kirby\Exception\PermissionException If the user does not have the required permission 77 | */ 78 | public function checkPermission(string $permission): void 79 | { 80 | if ($this->hasPermission($permission) !== true) { 81 | throw new PermissionException([ 82 | 'key' => 'versions.permission', 83 | 'data' => compact('permission') 84 | ]); 85 | } 86 | } 87 | 88 | /** 89 | * Ensures that the exports directory exists and 90 | * cleans all expired exports from the media folder 91 | * 92 | * @return void 93 | */ 94 | public function cleanExports(): void 95 | { 96 | $root = $this->exportRoot(); 97 | 98 | // ensure that the directory exists 99 | Dir::make($root); 100 | 101 | // check for files that have not been modified in the last two hours 102 | foreach (Dir::files($root, null, true) as $file) { 103 | $modified = filemtime($file); 104 | if (is_int($modified) === true && $modified < time() - 2 * 60 * 60) { 105 | unlink($file); 106 | } 107 | } 108 | } 109 | 110 | /** 111 | * Returns the root of the exports directory 112 | * 113 | * @return string 114 | */ 115 | public function exportRoot(): string 116 | { 117 | return $this->kirby->roots()->media() . '/versions-export'; 118 | } 119 | 120 | /** 121 | * Returns the URL of the exports directory 122 | * 123 | * @return string 124 | */ 125 | public function exportUrl(): string 126 | { 127 | return $this->kirby->urls()->media() . '/versions-export'; 128 | } 129 | 130 | /** 131 | * Runs a Git command in the specified instance directory 132 | * 133 | * @param \LukasBestle\Versions\Instance|null $instance Instance or `null` for the current site 134 | * @param string ...$arguments List of arguments to pass to the Git binary 135 | * @return string STDOUT and STDERR from Git 136 | * 137 | * @throws \Kirby\Exception\Exception If there is a Git error 138 | */ 139 | public function gitCommand(?Instance $instance, ...$arguments): string 140 | { 141 | // prepend the Git path and the repo path to the arguments 142 | $path = $instance ? $instance->contentRoot() : $this->kirby()->roots()->content(); 143 | $git = [$this->option('git.path'), '-C', $path]; 144 | array_unshift($arguments, ...$git); 145 | 146 | // assemble the command string and escape each argument 147 | $parts = []; 148 | foreach ($arguments as $argument) { 149 | $parts[] = escapeshellarg($argument); 150 | } 151 | $command = implode(' ', $parts); 152 | 153 | // execute the command, collect $output (including STDERR) and $returnVar 154 | $output = $returnVar = null; 155 | exec($command . ' 2>&1', $output, $returnVar); 156 | $output = implode("\n", $output); // exec() returns an array of output lines 157 | 158 | // validate that the command succeeded 159 | if ($returnVar !== 0) { 160 | throw new Exception([ 161 | 'key' => 'versions.git.nonzero', 162 | 'data' => ['message' => $output] 163 | ]); 164 | } 165 | 166 | return $output; 167 | } 168 | 169 | /** 170 | * Returns whether the current user has the specified permission 171 | * 172 | * @param string $permission 173 | * @return bool 174 | * 175 | * @throws \Kirby\Exception\LogicException If no user is logged in 176 | */ 177 | public function hasPermission(string $permission): bool 178 | { 179 | $user = $this->kirby->user(); 180 | if ($user === null) { 181 | throw new LogicException([ 182 | 'key' => 'versions.internal', 183 | 'data' => ['code' => 'user-not-logged-in'] 184 | ]); 185 | } 186 | 187 | $permissions = $user->role()->permissions(); 188 | return $permissions->for('lukasbestle.versions', $permission); 189 | } 190 | 191 | /** 192 | * Returns the singleton plugin instance 193 | * 194 | * @param \Kirby\Cms\App|null $kirby Kirby App instance to use (optional) 195 | * @return self 196 | */ 197 | public static function instance(?App $kirby = null): self 198 | { 199 | if ( 200 | self::$instance !== null && 201 | ($kirby === null || self::$instance->kirby() === $kirby) 202 | ) { 203 | return self::$instance; 204 | } 205 | 206 | return self::$instance = new self($kirby); 207 | } 208 | 209 | /** 210 | * Returns the collection of configured site instances 211 | * 212 | * @return \LukasBestle\Versions\Instances 213 | * 214 | * @throws \Kirby\Exception\Exception If the environment validation failed 215 | */ 216 | public function instances(): Instances 217 | { 218 | if ($this->instances !== null) { 219 | return $this->instances; 220 | } 221 | 222 | $this->validate(); 223 | return $this->instances = new Instances($this); 224 | } 225 | 226 | /** 227 | * Returns the Kirby App instance 228 | * 229 | * @return \Kirby\Cms\App 230 | */ 231 | public function kirby(): App 232 | { 233 | return $this->kirby; 234 | } 235 | 236 | /** 237 | * Returns a plugin option value 238 | * 239 | * @param string $key Option key 240 | * @return mixed 241 | */ 242 | public function option(string $key) 243 | { 244 | return $this->kirby()->option('lukasbestle.versions.' . $key); 245 | } 246 | 247 | /** 248 | * Returns the plugin state as array filtered 249 | * to the necessary data for the API 250 | * 251 | * @param array $additionalData Data that gets merged with the plugin 252 | * data or returned when the user doesn't 253 | * have the `access` permission 254 | * @return array 255 | */ 256 | public function toApiData(array $additionalData = []): array 257 | { 258 | if ($this->hasPermission('access') !== true) { 259 | return $additionalData; 260 | } 261 | 262 | $instances = $this->instances() 263 | ->toArray(function (Instance $instance) { 264 | $data = $instance->toArray(); 265 | 266 | // don't leak internal data 267 | unset($data['contentRoot'], $data['currentCommit']); 268 | 269 | return $data; 270 | }); 271 | 272 | $versions = $this->versions() 273 | ->sortBy('created', 'desc', 'name', 'asc') 274 | ->toArray(function (Version $version) { 275 | $data = $version->toArray(); 276 | 277 | // don't leak internal data 278 | unset($data['commit']); 279 | return $data; 280 | }); 281 | 282 | return array_merge(compact('instances', 'versions'), $additionalData); 283 | } 284 | 285 | /** 286 | * Returns the plugin state as array 287 | * 288 | * @return array 289 | */ 290 | public function toArray(): array 291 | { 292 | $map = function (object $object): array { 293 | /** @var Instance|Version $object */ 294 | return $object->toArray(); 295 | }; 296 | 297 | return [ 298 | 'instances' => $this->instances()->toArray($map), 299 | 'versions' => $this->versions()->toArray($map) 300 | ]; 301 | } 302 | 303 | /** 304 | * Validates the site's environment against 305 | * plugin requirements 306 | * 307 | * @return void 308 | * 309 | * @throws \Kirby\Exception\Exception If the environment validation failed 310 | */ 311 | public function validate(): void 312 | { 313 | // only run the validations once; 314 | // immediately set the flag to true to avoid infinite loops 315 | if ($this->validated === true) { 316 | return; 317 | } 318 | $this->validated = true; 319 | 320 | // try to get and parse the Git version 321 | $version = $this->gitCommand(null, 'version'); 322 | $matches = null; 323 | if (preg_match('/^git version ([0-9]+\.[0-9]+\.[0-9]+)/', $version, $matches) !== 1) { 324 | throw new Exception([ 325 | 'key' => 'versions.internal', 326 | 'data' => ['code' => 'git-version-unparseable'] 327 | ]); 328 | } 329 | 330 | // ensure that the Git version is recent enough 331 | if (version_compare($matches[1], '2.5.0', '<') === true) { 332 | throw new Exception([ 333 | 'key' => 'versions.git.version', 334 | 'data' => ['version' => $matches[1]] 335 | ]); 336 | } 337 | 338 | // initialize the Instances object 339 | // (which runs additional validations) 340 | $this->instances(); 341 | } 342 | 343 | /** 344 | * Returns the collection of the existing versions 345 | * 346 | * @return \LukasBestle\Versions\Versions 347 | * 348 | * @throws \Kirby\Exception\Exception If the environment validation failed 349 | */ 350 | public function versions(): Versions 351 | { 352 | if ($this->versions !== null) { 353 | return $this->versions; 354 | } 355 | 356 | $this->validate(); 357 | return $this->versions = new Versions($this); 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /src/classes/Version.php: -------------------------------------------------------------------------------- 1 | 19 | * @link https://github.com/lukasbestle/kirby-versions 20 | * @copyright Lukas Bestle 21 | * @license https://opensource.org/licenses/MIT 22 | */ 23 | class Version 24 | { 25 | use Properties; 26 | 27 | /** 28 | * Commit hash of this version 29 | * 30 | * @var string 31 | */ 32 | protected $commit; 33 | 34 | /** 35 | * Creation timestamp 36 | * 37 | * @var int|null 38 | */ 39 | protected $created = null; 40 | 41 | /** 42 | * Email address of the creator 43 | * 44 | * @var string|null 45 | */ 46 | protected $creatorEmail = null; 47 | 48 | /** 49 | * Name of the creator 50 | * 51 | * @var string|null 52 | */ 53 | protected $creatorName = null; 54 | 55 | /** 56 | * Custom label 57 | * 58 | * @var string 59 | */ 60 | protected $label; 61 | 62 | /** 63 | * Unique version name 64 | * 65 | * @var string 66 | */ 67 | protected $name; 68 | 69 | /** 70 | * Name of the instance where the version was 71 | * originally created 72 | * 73 | * @var string|null 74 | */ 75 | protected $originInstance = null; 76 | 77 | /** 78 | * Instance of the Plugin class 79 | * 80 | * @var \LukasBestle\Versions\Plugin 81 | */ 82 | protected $plugin; 83 | 84 | /** 85 | * Class constructor 86 | * 87 | * @param array $props 88 | */ 89 | public function __construct(array $props) 90 | { 91 | $this->setProperties($props); 92 | } 93 | 94 | /** 95 | * Returns the commit hash of this version 96 | * 97 | * @return string 98 | */ 99 | public function commit(): string 100 | { 101 | return $this->commit; 102 | } 103 | 104 | /** 105 | * Returns the creation timestamp; 106 | * only set if the tag is annotated 107 | * 108 | * @return int|null 109 | */ 110 | public function created(): ?int 111 | { 112 | return $this->created; 113 | } 114 | 115 | /** 116 | * Returns the email address of the creator; 117 | * only set if the tag is annotated 118 | * 119 | * @return string|null 120 | */ 121 | public function creatorEmail(): ?string 122 | { 123 | return $this->creatorEmail; 124 | } 125 | 126 | /** 127 | * Returns the name of the creator; 128 | * only set if the tag is annotated 129 | * 130 | * @return string|null 131 | */ 132 | public function creatorName(): ?string 133 | { 134 | return $this->creatorName; 135 | } 136 | 137 | /** 138 | * Deletes the Git tag behind this version 139 | * 140 | * @return void 141 | * 142 | * @throws \Kirby\Exception\LogicException If the version cannot be deleted 143 | * @throws \Kirby\Exception\Exception If there is a Git error 144 | */ 145 | public function delete(): void 146 | { 147 | if ($this->instances()->count() > 0) { 148 | throw new LogicException([ 149 | 'key' => 'versions.version.inUse' 150 | ]); 151 | } 152 | 153 | $this->plugin->gitCommand(null, 'tag', '-d', $this->name()); 154 | 155 | // update cache 156 | $this->plugin->versions()->remove($this->name()); 157 | } 158 | 159 | /** 160 | * Deploys the version to a specified instance 161 | * 162 | * @param \LukasBestle\Versions\Instance $instance 163 | * @return void 164 | * 165 | * @throws \Kirby\Exception\Exception If there is a Git error 166 | * @throws \Kirby\Exception\Exception If there are changes that cannot 167 | * be backed up automatically 168 | */ 169 | public function deployTo(Instance $instance): void 170 | { 171 | // if there are changes, we first need to create 172 | // an automatic version to back them up 173 | if ( 174 | $instance->changes()->overall() !== [] || 175 | $instance->changes()->lockFiles() !== [] 176 | ) { 177 | $instance->prepareCreation(); 178 | 179 | /** @var string $label */ 180 | $label = I18n::translate('versions.name.autosave'); 181 | $instance->createVersion($label); 182 | } 183 | 184 | // now we can deploy the version to the instance 185 | $this->plugin->gitCommand($instance, 'checkout', $this->name()); 186 | 187 | // update cache 188 | $instance->setCurrentCommit($this->commit()); 189 | } 190 | 191 | /** 192 | * Exports the version as a ZIP file 193 | * 194 | * @return array ZIP `url` and `filesize` 195 | * 196 | * @throws \Kirby\Exception\Exception If there is a Git error 197 | */ 198 | public function export(): array 199 | { 200 | // generate a hard to guess filename 201 | $filename = $this->name() . '_' . substr($this->commit(), 0, 7) . '.zip'; 202 | 203 | // build the absolute path and URL 204 | $path = $this->plugin->exportRoot() . '/' . $filename; 205 | $url = $this->plugin->exportUrl() . '/' . $filename; 206 | 207 | // check if the export already exists 208 | if (is_file($path) === true) { 209 | // ensure that the file is preserved for another two hours 210 | touch($path); 211 | } else { 212 | Dir::make($this->plugin->exportRoot()); 213 | $this->plugin->gitCommand(null, 'archive', '--format=zip', '-o', $path, $this->name()); 214 | } 215 | 216 | return [ 217 | 'filesize' => F::niceSize($path), 218 | 'url' => $url 219 | ]; 220 | } 221 | 222 | /** 223 | * Returns the list of instances that 224 | * currently use this version 225 | * 226 | * @return \Kirby\Toolkit\Collection 227 | */ 228 | public function instances(): Collection 229 | { 230 | return $this->plugin->instances()->filterBy('currentCommit', $this->commit()); 231 | } 232 | 233 | /** 234 | * Returns the custom label 235 | * 236 | * @return string 237 | */ 238 | public function label(): string 239 | { 240 | return $this->label; 241 | } 242 | 243 | /** 244 | * Returns the unique version name 245 | * 246 | * @return string 247 | */ 248 | public function name(): string 249 | { 250 | return $this->name; 251 | } 252 | 253 | /** 254 | * Returns the name of the instance where 255 | * the version was originally created (if known) 256 | * 257 | * @return string|null 258 | */ 259 | public function originInstance(): ?string 260 | { 261 | return $this->originInstance; 262 | } 263 | 264 | /** 265 | * Returns the version data as array 266 | * 267 | * @return array 268 | * 269 | * @throws \Kirby\Exception\Exception If there is a Git error 270 | */ 271 | public function toArray(): array 272 | { 273 | $array = $this->propertiesToArray(); 274 | $array['instances'] = $this->instances()->keys(); 275 | 276 | ksort($array); 277 | return $array; 278 | } 279 | 280 | /** 281 | * Sets the commit hash of this version 282 | * 283 | * @param string $commit 284 | * @return self 285 | */ 286 | protected function setCommit(string $commit): self 287 | { 288 | $this->commit = $commit; 289 | return $this; 290 | } 291 | 292 | /** 293 | * Sets the creation timestamp 294 | * 295 | * @param int|string|null $created 296 | * @return self 297 | * 298 | * @throws \Kirby\Exception\InvalidArgumentException If the value cannot be parsed 299 | */ 300 | protected function setCreated($created = null): self 301 | { 302 | if (is_string($created) === true) { 303 | $created = $created ? strtotime($created) : null; 304 | } 305 | 306 | if (is_int($created) !== true && $created !== null) { 307 | throw new InvalidArgumentException([ 308 | 'key' => 'versions.internal', 309 | 'data' => ['code' => 'version-invalid-created-value'] 310 | ]); 311 | } 312 | 313 | $this->created = $created; 314 | return $this; 315 | } 316 | 317 | /** 318 | * Sets the email address of the creator 319 | * 320 | * @param string|null $creatorEmail 321 | * @return self 322 | */ 323 | protected function setCreatorEmail(?string $creatorEmail = null): self 324 | { 325 | // Git will output an empty string if the value is not available 326 | if ($creatorEmail === '') { 327 | $creatorEmail = null; 328 | } elseif ($creatorEmail !== null) { 329 | // trim the angle brackets around the email address 330 | // that come from Git's output 331 | $creatorEmail = trim($creatorEmail, '<>'); 332 | } 333 | 334 | $this->creatorEmail = $creatorEmail; 335 | return $this; 336 | } 337 | 338 | /** 339 | * Sets the name of the creator 340 | * 341 | * @param string|null $creatorName 342 | * @return self 343 | */ 344 | protected function setCreatorName(?string $creatorName = null): self 345 | { 346 | // Git will output an empty string if the value is not available 347 | if ($creatorName === '') { 348 | $creatorName = null; 349 | } 350 | 351 | $this->creatorName = $creatorName; 352 | return $this; 353 | } 354 | 355 | /** 356 | * Sets the custom label 357 | * 358 | * @param string $label 359 | * @return self 360 | */ 361 | protected function setLabel(string $label): self 362 | { 363 | $this->label = $label; 364 | return $this; 365 | } 366 | 367 | /** 368 | * Sets the unique version name 369 | * 370 | * @param string $name 371 | * @return self 372 | */ 373 | protected function setName(string $name): self 374 | { 375 | $this->name = $name; 376 | return $this; 377 | } 378 | 379 | /** 380 | * Sets the name of the instance where 381 | * the version was originally created 382 | * 383 | * @param string|null $originInstance 384 | * @return self 385 | */ 386 | protected function setOriginInstance(?string $originInstance = null): self 387 | { 388 | $this->originInstance = $originInstance; 389 | return $this; 390 | } 391 | 392 | /** 393 | * Sets the instance of the Plugin class 394 | * 395 | * @param \LukasBestle\Versions\Plugin $plugin 396 | * @return self 397 | */ 398 | protected function setPlugin(Plugin $plugin): self 399 | { 400 | $this->plugin = $plugin; 401 | return $this; 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/classes/Versions.php: -------------------------------------------------------------------------------- 1 | 15 | * @link https://github.com/lukasbestle/kirby-versions 16 | * @copyright Lukas Bestle 17 | * @license https://opensource.org/licenses/MIT 18 | * 19 | * @method \LukasBestle\Versions\Version findBy(string $attribute, $value) 20 | * @method \LukasBestle\Versions\Version get($key, $default = null) 21 | */ 22 | class Versions extends Collection 23 | { 24 | /** 25 | * Instance of the Plugin class 26 | * 27 | * @var \LukasBestle\Versions\Plugin 28 | */ 29 | protected $plugin; 30 | 31 | /** 32 | * Class constructor 33 | * 34 | * @param \LukasBestle\Versions\Plugin $plugin 35 | * 36 | * @throws \Kirby\Exception\Exception If there is a Git error 37 | */ 38 | public function __construct(Plugin $plugin) 39 | { 40 | $this->plugin = $plugin; 41 | 42 | // set case-sensitive mode 43 | parent::__construct([], true); 44 | 45 | $this->update(); 46 | $this->autodelete(); 47 | } 48 | 49 | /** 50 | * Automatically cleans up old versions 51 | * 52 | * @return void 53 | * 54 | * @throws \Kirby\Exception\Exception If there is a Git error 55 | */ 56 | public function autodelete(): void 57 | { 58 | $autodeleteAge = $this->plugin->option('autodelete.age'); 59 | $autodeleteCount = $this->plugin->option('autodelete.count'); 60 | 61 | // never delete non-annotated versions automatically and 62 | // always keep versions that are still in use 63 | $candidates = $this 64 | ->filterBy('created', '!=', null) 65 | ->filter(function ($version) { 66 | return $version->instances()->count() === 0; 67 | }); 68 | 69 | // first delete by age 70 | if (is_int($autodeleteAge) === true) { 71 | $toDelete = $candidates->filterBy('created', '<', time() - $autodeleteAge); 72 | foreach ($toDelete as $key => $version) { 73 | $version->delete(); 74 | unset($candidates->$key); 75 | } 76 | } 77 | 78 | // now check how many old versions we still need to delete by count 79 | if (is_int($autodeleteCount) === true) { 80 | // ensure that the number is never negative 81 | $numDelete = max(count($this) - $autodeleteCount, 0); 82 | if ($numDelete === 0) { 83 | return; 84 | } 85 | 86 | // delete from the oldest version 87 | foreach ($candidates->sortBy('created', SORT_ASC) as $key => $version) { 88 | $version->delete(); 89 | 90 | $numDelete--; 91 | if ($numDelete === 0) { 92 | break; 93 | } 94 | } 95 | } 96 | } 97 | 98 | /** 99 | * Returns the specified version or throws an 100 | * Exception if not found 101 | * 102 | * @param string $name 103 | * @return \LukasBestle\Versions\Version 104 | * 105 | * @throws \Kirby\Exception\NotFoundException If the instance was not found 106 | */ 107 | public function findOrException(string $name): Version 108 | { 109 | $version = $this->find($name); 110 | if (!$version) { 111 | throw new NotFoundException([ 112 | 'key' => 'versions.notFound.version', 113 | 'data' => ['name' => $name] 114 | ]); 115 | } 116 | 117 | return $version; 118 | } 119 | 120 | /** 121 | * Loads the current versions from the repository 122 | * 123 | * @return void 124 | * 125 | * @throws \Kirby\Exception\Exception If there is a Git error 126 | */ 127 | public function update(): void 128 | { 129 | $command = ['tag', '--list', '--format', '%(refname:short) %(object) %(objectname) %(taggerdate) %(taggeremail) %(taggername) %(contents:subject)']; 130 | $versionsRaw = $this->plugin->gitCommand(null, ...$command); 131 | 132 | // return if there are no versions 133 | if ($versionsRaw === '') { 134 | $this->data = []; 135 | return; 136 | } 137 | 138 | // parse each version line 139 | $versions = []; 140 | foreach (explode("\n", $versionsRaw) as $version) { 141 | list($name, $commit1, $commit2, $created, $creatorEmail, $creatorName, $label) = explode(' ', $version); 142 | 143 | // depending if the tag is annotated or not, 144 | // either one of these two fields is set 145 | $commit = $commit1 ? $commit1 : $commit2; 146 | 147 | // extract the origin instance name from the label 148 | $originInstance = null; 149 | if (Str::contains($label, ':::') === true) { 150 | $originInstance = Str::before($label, ':::'); 151 | $label = Str::after($label, ':::'); 152 | } 153 | 154 | $props = compact('name', 'commit', 'created', 'creatorEmail', 'creatorName', 'label', 'originInstance'); 155 | $props['plugin'] = $this->plugin; 156 | $versions[$name] = new Version($props); 157 | } 158 | 159 | // set the new versions in the collection 160 | $this->data = $versions; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/config/api.php: -------------------------------------------------------------------------------- 1 | [ 7 | [ 8 | /** 9 | * All plugin data 10 | * Returns all instances and all versions with all data 11 | * necessary to display the Versions view in the Panel 12 | * 13 | * @return array 14 | */ 15 | 'pattern' => 'versions', 16 | 'method' => 'GET', 17 | 'action' => function (): array { 18 | /** @psalm-scope-this \Kirby\Http\Route */ 19 | $plugin = Plugin::instance($this->kirby()); 20 | $plugin->checkPermission('access'); 21 | 22 | return $plugin->toApiData(); 23 | } 24 | ], 25 | [ 26 | /** 27 | * Prepare version creation 28 | * Stages all changes and validates that the version 29 | * can be created based on the current set of changes 30 | * 31 | * @param string body:instance Name of the instance to create the version from 32 | * @return array List of staged changes 33 | */ 34 | 'pattern' => 'versions/prepareCreation', 35 | 'method' => 'POST', 36 | 'action' => function (): array { 37 | /** @psalm-scope-this \Kirby\Http\Route */ 38 | $plugin = Plugin::instance($this->kirby()); 39 | $plugin->checkPermission('create'); 40 | 41 | $instance = $plugin->instances()->findOrException($this->requestBody('instance')); 42 | 43 | $instance->prepareCreation(); 44 | return $instance->changes()->inIndex(); 45 | } 46 | ], 47 | [ 48 | /** 49 | * Create version 50 | * Commits the previously prepared version 51 | * 52 | * @param string body:instance Name of the instance to create the version from 53 | * @param string body:label Custom version label 54 | * @return array Updated plugin data 55 | */ 56 | 'pattern' => 'versions/create', 57 | 'method' => 'POST', 58 | 'action' => function (): array { 59 | /** @psalm-scope-this \Kirby\Http\Route */ 60 | $plugin = Plugin::instance($this->kirby()); 61 | $plugin->checkPermission('create'); 62 | 63 | $instance = $plugin->instances()->findOrException($this->requestBody('instance')); 64 | 65 | $instance->createVersion($this->requestBody('label')); 66 | return $plugin->toApiData(['status' => 'ok']); 67 | } 68 | ], 69 | [ 70 | /** 71 | * Delete version 72 | * Deletes a version's Git tag 73 | * 74 | * @param string url:version Unique version name 75 | * @return array Updated plugin data 76 | */ 77 | 'pattern' => 'versions/versions/(:any)', 78 | 'method' => 'DELETE', 79 | 'action' => function (string $versionName): array { 80 | /** @psalm-scope-this \Kirby\Http\Route */ 81 | $plugin = Plugin::instance($this->kirby()); 82 | $plugin->checkPermission('delete'); 83 | 84 | $version = $plugin->versions()->findOrException($versionName); 85 | 86 | $version->delete(); 87 | return $plugin->toApiData(['status' => 'ok']); 88 | } 89 | ], 90 | [ 91 | /** 92 | * Deploy version 93 | * Deploys a version to a specified instance 94 | * 95 | * @param string body:instance Name of the instance to deploy to 96 | * @param string url:version Unique version name 97 | * @return array Updated plugin data 98 | */ 99 | 'pattern' => 'versions/versions/(:any)/deploy', 100 | 'method' => 'POST', 101 | 'action' => function (string $versionName): array { 102 | /** @psalm-scope-this \Kirby\Http\Route */ 103 | $plugin = Plugin::instance($this->kirby()); 104 | $plugin->checkPermission('deploy'); 105 | 106 | $instance = $plugin->instances()->findOrException($this->requestBody('instance')); 107 | $version = $plugin->versions()->findOrException($versionName); 108 | 109 | $version->deployTo($instance); 110 | return $plugin->toApiData(['status' => 'ok']); 111 | } 112 | ], 113 | [ 114 | /** 115 | * Export version 116 | * Returns the URL to a ZIP file of the given version 117 | * 118 | * @param string url:version Unique version name 119 | * @return array ZIP `url`, version `name` and `label`, `filesize` 120 | */ 121 | 'pattern' => 'versions/versions/(:any)/export', 122 | 'method' => 'POST', 123 | 'action' => function (string $versionName): array { 124 | /** @psalm-scope-this \Kirby\Http\Route */ 125 | $plugin = Plugin::instance($this->kirby()); 126 | $plugin->checkPermission('export'); 127 | 128 | $version = $plugin->versions()->findOrException($versionName); 129 | 130 | return array_merge($version->export(), [ 131 | 'label' => $version->label(), 132 | 'name' => $version->name() 133 | ]); 134 | } 135 | ] 136 | ] 137 | ]; 138 | -------------------------------------------------------------------------------- /src/config/areas.php: -------------------------------------------------------------------------------- 1 | function ($kirby) { 9 | try { 10 | $accessPermission = Plugin::instance()->hasPermission('access'); 11 | } catch (LogicException $e) { 12 | // area was loaded by Kirby without a logged-in user 13 | $accessPermission = false; 14 | } 15 | 16 | return [ 17 | 'label' => I18n::translate('view.versions'), 18 | 'icon' => 'layers', 19 | 'menu' => $accessPermission ? true : 'disabled', 20 | 'views' => [ 21 | [ 22 | 'pattern' => 'versions', 23 | 'action' => function () { 24 | return [ 25 | 'component' => 'lbvs-view', 26 | ]; 27 | } 28 | ] 29 | ] 30 | ]; 31 | } 32 | ]; 33 | -------------------------------------------------------------------------------- /src/config/hooks.php: -------------------------------------------------------------------------------- 1 | function () { 7 | /** @psalm-scope-this \Kirby\Cms\App */ 8 | Plugin::instance($this)->cleanExports(); 9 | } 10 | ]; 11 | -------------------------------------------------------------------------------- /src/config/i18n/de.php: -------------------------------------------------------------------------------- 1 | 'Versionen', 5 | 6 | 'error.versions.git.nonzero' => 'Ein Git-Fehler ist aufgetreten: { message }', 7 | 'error.versions.git.version' => 'Das Versionen-Plugin benötigt Git 2.5+, du hast Git { version }', 8 | 'error.versions.internal' => 'Interner Fehler im Versionen-Plugin (Fehlercode { code })', 9 | 'error.versions.instance.noRepo' => 'Der content-Ordner der Instanz { instance } ist nicht mit einem Git-Repo verbunden, bitte entweder ein neues Repo einrichten oder es als Worktree verbinden', 10 | 'error.versions.instance.noWorktree' => 'Der content-Ordner der Instanz { instance } ist kein Worktree des content-Ordners der aktuellen Site, bitte verbinde die beiden Instanzen als Worktrees', 11 | 'error.versions.instance.onBranch' => 'Der content-Ordner der Instanz { instance } hat noch einen ausgecheckten Branch, bitte führe `git checkout` mit dem aktuellsten Git-Tag aus', 12 | 'error.versions.lockFiles' => 'Aufgrund von ungespeicherten Änderungen an folgenden Seiten und Dateien kann derzeit keine Version erstellt werden:', 13 | 'error.versions.noChanges' => 'Es gibt derzeit keine Änderungen, mit denen eine Version erstellt werden kann', 14 | 'error.versions.notPrepared' => 'Die Version wurde noch nicht vorbereitet', 15 | 'error.versions.notFound.instance' => 'Die Instanz { name } existiert nicht', 16 | 'error.versions.notFound.version' => 'Die Version { name } existiert nicht', 17 | 'error.versions.permission' => 'Du darfst dies nicht tun (fehlende { permission }-Berechtigung)', 18 | 'error.versions.version.inUse' => 'Die Version wird derzeit verwendet', 19 | 20 | 'versions.button.copyLink' => 'Link kopieren', 21 | 'versions.button.create' => 'Version erstellen', 22 | 'versions.button.delete' => 'Löschen', 23 | 'versions.button.deploy' => 'Verwenden', 24 | 'versions.button.download' => 'Herunterladen', 25 | 'versions.button.export' => 'Exportieren', 26 | 27 | 'versions.label.changes' => 'Änderungen', 28 | 'versions.label.creation' => 'Erstellung', 29 | 'versions.label.creationData' => '{created} ({creator})', 30 | 'versions.label.current' => 'aktuell', 31 | 'versions.label.empty' => 'Noch keine Versionen', 32 | 'versions.label.fileSize' => 'Dateigröße', 33 | 'versions.label.instances' => 'Instanzen', 34 | 'versions.label.label' => 'Beschriftung', 35 | 'versions.label.originInstance' => 'Ursprungsinstanz', 36 | 'versions.label.status.+' => 'Status: erstellt', 37 | 'versions.label.status.-' => 'Status: gelöscht', 38 | 'versions.label.status.C' => 'Status: kopiert', 39 | 'versions.label.status.M' => 'Status: geändert', 40 | 'versions.label.status.R' => 'Status: umbenannt', 41 | 'versions.label.targetInstance' => 'Zielinstanz', 42 | 'versions.label.versionName' => 'Versionsname', 43 | 'versions.label.versions' => 'Versionen', 44 | 45 | 'versions.message.delete' => 'Möchtest du diese Version wirklich löschen?', 46 | 'versions.message.exporting' => 'Version wird exportiert...', 47 | 48 | 'versions.name.autosave' => 'Automatischer Schnappschuss', 49 | 'versions.name.local' => 'Lokal' 50 | ]; 51 | -------------------------------------------------------------------------------- /src/config/i18n/en.php: -------------------------------------------------------------------------------- 1 | 'Versions', 5 | 6 | 'error.versions.git.nonzero' => 'A Git error occurred: { message }', 7 | 'error.versions.git.version' => 'The Versions plugin requires Git 2.5+, you have Git { version }', 8 | 'error.versions.internal' => 'Internal error in the Versions plugin (error code { code })', 9 | 'error.versions.instance.noRepo' => 'The content directory of instance { instance } is not connected to a Git repo, please either initialize a new repo or connect it as a worktree', 10 | 'error.versions.instance.noWorktree' => 'The content directory of instance { instance } is not a worktree of the content directory of the current site, please connect the two instances as worktrees', 11 | 'error.versions.instance.onBranch' => 'The content directory of instance { instance } still has a checked out branch, please run `git checkout` with the latest Git tag', 12 | 'error.versions.lockFiles' => 'A version cannot be created as some pages or files have unsaved changes:', 13 | 'error.versions.noChanges' => 'There are no changes to create a version from', 14 | 'error.versions.notPrepared' => 'The version has not been prepared yet', 15 | 'error.versions.notFound.instance' => 'The instance { name } does not exist', 16 | 'error.versions.notFound.version' => 'The version { name } does not exist', 17 | 'error.versions.permission' => 'You are not allowed to do this (missing { permission } permission)', 18 | 'error.versions.version.inUse' => 'The version is currently in use', 19 | 20 | 'versions.button.copyLink' => 'Copy link', 21 | 'versions.button.create' => 'Create version', 22 | 'versions.button.delete' => 'Delete', 23 | 'versions.button.deploy' => 'Deploy', 24 | 'versions.button.download' => 'Download', 25 | 'versions.button.export' => 'Export', 26 | 27 | 'versions.label.changes' => 'Changes', 28 | 'versions.label.creation' => 'Creation', 29 | 'versions.label.creationData' => '{created} ({creator})', 30 | 'versions.label.current' => 'current', 31 | 'versions.label.empty' => 'No versions yet', 32 | 'versions.label.fileSize' => 'File size', 33 | 'versions.label.instances' => 'Instances', 34 | 'versions.label.label' => 'Label', 35 | 'versions.label.originInstance' => 'Origin instance', 36 | 'versions.label.status.+' => 'status: created', 37 | 'versions.label.status.-' => 'status: deleted', 38 | 'versions.label.status.C' => 'status: copied', 39 | 'versions.label.status.M' => 'status: modified', 40 | 'versions.label.status.R' => 'status: renamed', 41 | 'versions.label.targetInstance' => 'Target instance', 42 | 'versions.label.versionName' => 'Version name', 43 | 'versions.label.versions' => 'Versions', 44 | 45 | 'versions.message.delete' => 'Do you really want to delete this version?', 46 | 'versions.message.exporting' => 'Exporting the version...', 47 | 48 | 'versions.name.autosave' => 'Automatic snapshot', 49 | 'versions.name.local' => 'Local', 50 | ]; 51 | -------------------------------------------------------------------------------- /src/config/i18n/fr.php: -------------------------------------------------------------------------------- 1 | 'Versions', 5 | 6 | 'error.versions.git.nonzero' => 'Une erreur Git s\'est produite: { message }', 7 | 'error.versions.git.version' => 'Le plugin Versions nécessite Git 2.5+, vous avez Git { version }', 8 | 'error.versions.internal' => 'Erreur interne dans le plugin Versions (code d\'erreur { code })', 9 | 'error.versions.instance.noRepo' => 'Le répertoire de contenu de l\'instance { instance } n\'est pas connectée à un dépôt Git, merci d\'initialiser un nouveau dépôt où connectez le à un arbre de travail (worktree)', 10 | 'error.versions.instance.noWorktree' => 'Le répertoire de contenu de l\'instance { instance } n\'est pas un arbre de travail (worktree) du répertoire de contenu de ce site actuel, merci de connecter les deux instances en tant qu\'arbre de travail (worktree)', 11 | 'error.versions.instance.onBranch' => 'Le répertoire de contenu de l\'instance { instance } a toujours une branche \'checked out\', merci d\'exécuter un `git checkout` avec son dernier nom (tag) Git', 12 | 'error.versions.lockFiles' => 'Une version ne peut pas être créée car des pages ou des fichiers ont des modifications non sauvegardées:', 13 | 'error.versions.noChanges' => 'Il n\'y a pas de changements à partir duquel créer une version', 14 | 'error.versions.notPrepared' => 'La version n\'a pas encore été préparée', 15 | 'error.versions.notFound.instance' => 'L\'instance { name } n\'existe pas', 16 | 'error.versions.notFound.version' => 'La version { name } n\'existe pas', 17 | 'error.versions.permission' => 'Vous n\'êtes pas autorisés à faire celà (permission { permission } manquante)', 18 | 'error.versions.version.inUse' => 'Cette version est actuellement utilisée', 19 | 20 | 'versions.button.copyLink' => 'Copier le lien', 21 | 'versions.button.create' => 'Créer une version', 22 | 'versions.button.delete' => 'Supprimer', 23 | 'versions.button.deploy' => 'Déployer', 24 | 'versions.button.download' => 'Télécharger', 25 | 'versions.button.export' => 'Exporter', 26 | 27 | 'versions.label.changes' => 'Changements', 28 | 'versions.label.creation' => 'Création', 29 | 'versions.label.creationData' => '{created} ({creator})', 30 | 'versions.label.current' => 'actuelle', 31 | 'versions.label.empty' => 'Pas encore de version', 32 | 'versions.label.fileSize' => 'Taille du fichier', 33 | 'versions.label.instances' => 'Instances', 34 | 'versions.label.label' => 'Label', 35 | 'versions.label.originInstance' => 'Instance d\'origine', 36 | 'versions.label.status.+' => 'statut: créé', 37 | 'versions.label.status.-' => 'statut: supprimé', 38 | 'versions.label.status.C' => 'statut: copié', 39 | 'versions.label.status.M' => 'statut: modifié', 40 | 'versions.label.status.R' => 'statut: renommé', 41 | 'versions.label.targetInstance' => 'Instance cible', 42 | 'versions.label.versionName' => 'Nom de la version', 43 | 'versions.label.versions' => 'Versions', 44 | 45 | 'versions.message.delete' => 'Voulez-vous vraiment supprimer cette version?', 46 | 'versions.message.exporting' => 'Export de la version...', 47 | 48 | 'versions.name.autosave' => 'Instantané automatique (snapshot)', 49 | 'versions.name.local' => 'Local', 50 | ]; 51 | -------------------------------------------------------------------------------- /src/config/options.php: -------------------------------------------------------------------------------- 1 | 7 * 24 * 60 * 60, 7 | 8 | // number of versions to preserve at maximum; 9 | // defaults to 20 10 | 'autodelete.count' => 20, 11 | 12 | // path to the Git binary; 13 | // autodetected from PHP's `$PATH` if not set 14 | 'git.path' => 'git', 15 | 16 | // list of the site instances that can be managed from the Panel; 17 | // disabled by default (which will limit the access to the current site); 18 | // note that you can configure this differently in each instance's 19 | // `site/config.php` to limit the access from specific instances 20 | // (e.g. if a test instance shouldn't be able to access production) 21 | 'instances' => false 22 | ]; 23 | -------------------------------------------------------------------------------- /src/config/permissions.php: -------------------------------------------------------------------------------- 1 | true, 5 | 'create' => true, 6 | 'delete' => true, 7 | 'deploy' => true, 8 | 'export' => true 9 | ]; 10 | -------------------------------------------------------------------------------- /src/config/translations.php: -------------------------------------------------------------------------------- 1 | require __DIR__ . '/i18n/en.php', 6 | 7 | // additional translations 8 | 'de' => require __DIR__ . '/i18n/de.php', 9 | 'fr' => require __DIR__ . '/i18n/fr.php', 10 | ]; 11 | --------------------------------------------------------------------------------