diff --git a/.gitattributes b/.gitattributes index a664be3a85..c8987ade67 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,7 +13,7 @@ *.png binary *.gif binary -*.cs text=auto diff=csharp +*.cs text=auto diff=csharp *.vb text=auto *.c text=auto *.cpp text=auto @@ -41,9 +41,13 @@ *.fs text=auto *.fsx text=auto *.hs text=auto +*.json text=auto +*.xml text=auto -*.csproj text=auto merge=union -*.vbproj text=auto merge=union -*.fsproj text=auto merge=union -*.dbproj text=auto merge=union -*.sln text=auto eol=crlf merge=union +*.csproj text=auto merge=union +*.vbproj text=auto merge=union +*.fsproj text=auto merge=union +*.dbproj text=auto merge=union +*.sln text=auto eol=crlf merge=union + +*.gitattributes text=auto diff --git a/.github/BUILD.md b/.github/BUILD.md index c89a1be460..c6e870f396 100644 --- a/.github/BUILD.md +++ b/.github/BUILD.md @@ -1,4 +1,4 @@ -# Umbraco Cms Build +# Umbraco CMS Build ## Are you sure? @@ -39,6 +39,8 @@ To build Umbraco, fire up PowerShell and move to Umbraco's repository root (the build/build.ps1 +If you only see a build.bat-file, you're probably on the wrong branch. If you switch to the correct branch (dev-v8) the file will appear and you can build it. + You might run into [Powershell quirks](#powershell-quirks). ### Build Infrastructure @@ -66,7 +68,7 @@ The Visual Studio object is `null` when Visual Studio has not been detected (eg * `Path`: Visual Studio installation path (eg some place under `Program Files`) * `Major`: Visual Studio major version (eg `15` for VS 2017) * `Minor`: Visual Studio minor version -* `MsBUild`: the absolute path to the MsBuild executable +* `MsBuild`: the absolute path to the MsBuild executable #### GetUmbracoVersion @@ -209,4 +211,4 @@ The best solution is to unblock the Zip file before un-zipping: right-click the ### Git Quirks -Git might have issues dealing with long file paths during build. You may want/need to enable `core.longpaths` support (see [this page](https://github.com/msysgit/msysgit/wiki/Git-cannot-create-a-file-or-directory-with-a-long-path) for details). \ No newline at end of file +Git might have issues dealing with long file paths during build. You may want/need to enable `core.longpaths` support (see [this page](https://github.com/msysgit/msysgit/wiki/Git-cannot-create-a-file-or-directory-with-a-long-path) for details). diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 0e79851c0b..1526c54656 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -29,4 +29,4 @@ Don't rest on your laurels and never accept the status quo. Contribute and give ## Friendly -Don’t judge upon mistakes made but rather upon the speed and quality with which mistakes are corrected. Friendly posts and contributions generate smiles and builds long lasting relationships. \ No newline at end of file +Don’t judge upon mistakes made but rather upon the speed and quality with which mistakes are corrected. Friendly posts and contributions generate smiles and build long lasting relationships. \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 84115b946a..cea5859486 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,15 +2,15 @@ 👍🎉 First off, thanks for taking the time to contribute! 🎉👍 -The following is a set of guidelines for contributing to Umbraco CMS. +The following is a set of guidelines, for contributing to Umbraco CMS. -These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. +These are mostly guidelines, not rules. Use your best judgement, and feel free to propose changes to this document in a pull request. Remember, we're a friendly bunch and are happy with whatever contribution you might provide. Below are guidelines for success that we've gathered over the years. If you choose to ignore them then we still love you 💖. **Code of conduct** -This project and everyone participating in it is governed by the [our Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [Sebastiaan Janssen - sj@umbraco.dk](mailto:sj@umbraco.dk). +This project and everyone participating in it, is governed by the [our Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [Sebastiaan Janssen - sj@umbraco.dk](mailto:sj@umbraco.dk). **Table of contents** @@ -38,11 +38,11 @@ This document gives you a quick overview on how to get started. ### Guidelines for contributions we welcome -Not all changes are wanted, so on occassion we might close a PR without merging it. We will give you feedback why we can't accept your changes and we'll be nice about it, thanking you for spending your valuable time. +Not all changes are wanted, so on occasion we might close a PR without merging it. We will give you feedback why we can't accept your changes and we'll be nice about it, thanking you for spending your valuable time. -We have [documented what we consider small and large changes](CONTRIBUTION_GUIDELINES.md). Make sure to talk to us before making large changes. +We have [documented what we consider small and large changes](CONTRIBUTION_GUIDELINES.md). Make sure to talk to us before making large changes, so we can ensure that you don't put all your hard work into something we would not be able to merge. -Remember, if an issue is in the `Up for grabs` list or you've asked for some feedback before you sent us a PR, your PR will not be closed as unwanted. +Remember, it is always worth working on an issue from the `Up for grabs` list or even asking for some feedback before you send us a PR. This way, your PR will not be closed as unwanted. ### What can I start with? @@ -59,37 +59,38 @@ Great question! The short version goes like this: * **Clone** - when GitHub has created your fork, you can clone it in your favorite Git tool ![Clone the fork](img/clonefork.png) - + + * **Switch to the correct branch** - switch to the `v8/contrib` branch * **Build** - build your fork of Umbraco locally as described in [building Umbraco from source code](BUILD.md) * **Change** - make your changes, experiment, have fun, explore and learn, and don't be afraid. We welcome all contributions and will [happily give feedback](#questions) - * **Commit** - done? Yay! 🎉 **Important:** create a new branch now and name it after the issue you're fixing, we usually follow the format: `temp-12345`. This means it's a temporary branch for the particular issue you're working on, in this case `12345`. When you have a branch, commit your changes. Don't commit to `v8/dev`, create a new branch first. + * **Commit** - done? Yay! 🎉 **Important:** create a new branch now and name it after the issue you're fixing, we usually follow the format: `temp-12345`. This means it's a temporary branch for the particular issue you're working on, in this case `12345`. When you have a branch, commit your changes. Don't commit to `v8/contrib`, create a new branch first. * **Push** - great, now you can push the changes up to your fork on GitHub - * **Create pull request** - exciting! You're ready to show us your changes (or not quite ready, you just need some feedback to progress). GitHub has picked up on the new branch you've pushed and will offer to create a Pull Request. Click that green button and away you go. + * **Create pull request** - exciting! You're ready to show us your changes (or not quite ready, you just need some feedback to progress - you can now make use of GitHub's draft pull request status, detailed [here] (https://github.blog/2019-02-14-introducing-draft-pull-requests/)). GitHub has picked up on the new branch you've pushed and will offer to create a Pull Request. Click that green button and away you go. ![Create a pull request](img/createpullrequest.png) ### Pull requests The most successful pull requests usually look a like this: - * Fill in the required template + * Fill in the required template, linking your pull request to an issue on the [issue tracker,](https://github.com/umbraco/Umbraco-CMS/issues) if applicable. * Include screenshots and animated GIFs in your pull request whenever possible. - * Unit tests, while optional are awesome, thank you! - * New code is commented with documentation from which [the reference documentation](https://our.umbraco.com/documentation/Reference/) is generated + * Unit tests, while optional, are awesome. Thank you! + * New code is commented with documentation from which [the reference documentation](https://our.umbraco.com/documentation/Reference/) is generated. -Again, these are guidelines, not strict requirements. +Again, these are guidelines, not strict requirements. However, the more information that you give to us, the more we have to work with when considering your contributions. Good documentation of a pull request can really speed up the time it takes to review and merge your work! ## Reviews -You've sent us your first contribution, congratulations! Now what? +You've sent us your first contribution - congratulations! Now what? -The [pull request team](#the-pr-team) can now start reviewing your proposed changes and give you feedback on them. If it's not perfect, we'll either fix up what we need or we can request you to make some additional changes. +The [pull request team](#the-pr-team) can now start reviewing your proposed changes and give you feedback on them. If it's not perfect, we'll either fix up what we need or we can request that you make some additional changes. We have [a process in place which you can read all about](REVIEW_PROCESS.md). The very abbreviated version is: - Your PR will get a reply within 48 hours - An in-depth reply will be added within at most 2 weeks - The PR will be either merged or rejected within at most 4 weeks -- Sometimes it is difficult to meet these timelines and we'll talk to you +- Sometimes it is difficult to meet these timelines and we'll talk to you if this is the case. ### Styleguides @@ -99,21 +100,21 @@ That said, the Umbraco development team likes to follow the hints that ReSharper ### The PR team -The pull request team consists of a member of Umbraco HQ, [Sebastiaan](https://github.com/nul800sebastiaan), who gets assistance from the following community members +The pull request team consists of one member of Umbraco HQ, [Sebastiaan](https://github.com/nul800sebastiaan), who gets assistance from the following community members who have comitted to volunteering their free time: - [Anders Bjerner](https://github.com/abjerner) - [Dave Woestenborghs](https://github.com/dawoe) - [Emma Burstow](https://github.com/emmaburstow) - [Poornima Nayar](https://github.com/poornimanayar) -These wonderful volunteers will provide you with a first reply to your PR, review and test out your changes and might ask more questions. After that they'll let Umbraco HQ know if everything seems okay. +These wonderful people aim to provide you with a first reply to your PR, review and test out your changes and on occasions, they might ask more questions. If they are happy with your work, they'll let Umbraco HQ know by approving the PR. Hq will have final sign-off and will check the work again before it is merged. ### Questions? -You can get in touch with [the PR team](#the-pr-team) in multiple ways, we love open conversations and we are a friendly bunch. No question you have is stupid. Any questions you have usually helps out multiple people with the same question. Ask away: +You can get in touch with [the PR team](#the-pr-team) in multiple ways; we love open conversations and we are a friendly bunch. No question you have is stupid. Any question you have usually helps out multiple people with the same question. Ask away: -- If there's an existing issue on the issue tracker then that's a good place to leave questions and discuss how to start or move forward -- Unsure where to start? Did something not work as expected? Try leaving a note in the ["Contributing to Umbraco"](https://our.umbraco.com/forum/contributing-to-umbraco-cms/) forum, the team monitors that one closely +- If there's an existing issue on the issue tracker then that's a good place to leave questions and discuss how to start or move forward. +- Unsure where to start? Did something not work as expected? Try leaving a note in the ["Contributing to Umbraco"](https://our.umbraco.com/forum/contributing-to-umbraco-cms/) forum. The team monitors that one closely, so one of us will be on hand and ready to point you in the right direction. ## Working with the code @@ -125,19 +126,19 @@ In order to build the Umbraco source code locally, first make sure you have the * Node v10+ * npm v6.4.1+ -The easiest way to get started is to run `build.ps1` which will build both the backoffice (also known as "Belle") and the Umbraco core. You can then easily start debugging from Visual Studio, or if you need to debug Belle you can run `gulp dev` in `src\Umbraco.Web.UI.Client`. See [this page](BUILD.md) for more details. +The easiest way to get started is to open `src\umbraco.sln` in Visual Studio 2017 (version 15.9.7 or higher, [the community edition is free](https://www.visualstudio.com/thank-you-downloading-visual-studio/?sku=Community&rel=15) for you to use to contribute to Open Source projects). In Visual Studio, find the Task Runner Explorer (in the View menu under Other Windows) and run the build task under the gulpfile. -Alternatively, you can open `src\umbraco.sln` in Visual Studio 2017 (version 15.9.7 or higher, [the community edition is free](https://www.visualstudio.com/thank-you-downloading-visual-studio/?sku=Community&rel=15) for you to use to contribute to Open Source projects). In Visual Studio, find the Task Runner Explorer (in the View menu under Other Windows) and run the build task under the gulpfile. +Alternatively, you can run `build.ps1` from the Powershell command line, which will build both the backoffice (also known as "Belle") and the Umbraco core. You can then easily start debugging from Visual Studio, or if you need to debug Belle you can run `gulp dev` in `src\Umbraco.Web.UI.Client`. See [this page](BUILD.md) for more details. ![Gulp build in Visual Studio](img/gulpbuild.png) -After this build completes, you should be able to hit `F5` in Visual Studio to build and run the project. A IISExpress webserver will start and the Umbraco installer will pop up in your browser, follow the directions there to get a working Umbraco install up and running. +After this build completes, you should be able to hit `F5` in Visual Studio to build and run the project. A IISExpress webserver will start and the Umbraco installer will pop up in your browser. Follow the directions there to get a working Umbraco install up and running. ### Working with the source code Some parts of our source code are over 10 years old now. And when we say "old", we mean "mature" of course! -There's two big areas that you should know about: +There are two big areas that you should know about: 1. The Umbraco backoffice is a extensible AngularJS app and requires you to run a `gulp dev` command while you're working with it, so changes are copied over to the appropriate directories and you can refresh your browser to view the results of your changes. You may need to run the following commands to set up gulp properly: @@ -146,30 +147,34 @@ There's two big areas that you should know about: npm install npm run build ``` + The caching for the back office has been described as 'aggressive' so we often find it's best when making back office changes to disable caching in the browser to help you to see the changes you're making. + 2. "The rest" is a C# based codebase, which is mostly ASP.NET MVC based. You can make changes, build them in Visual Studio, and hit `F5` to see the result. -To find the general areas of something you're looking to fix or improve, have a look at the following two parts of the API documentation. +To find the general areas for something you're looking to fix or improve, have a look at the following two parts of the API documentation. * [The AngularJS based backoffice files](https://our.umbraco.com/apidocs/ui/#/api) (to be found in `src\Umbraco.Web.UI.Client\src`) * [The C# application](https://our.umbraco.com/apidocs/csharp/) ### Which branch should I target for my contributions? -We like to use [Gitflow as much as possible](https://jeffkreeftmeijer.com/git-flow/), don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to something, usually `v8/dev`. Whatever the default is, that's where we'd like you to target your contributions. +We like to use [Gitflow as much as possible](https://jeffkreeftmeijer.com/git-flow/), but don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to something, usually `v8/contrib`. If you are working on v8, this is the branch you should be targetting. For v7 contributions, please target 'v7/dev'. + +Please note: we are no longer accepting features for v7 but will continue to merge bug fixes as and when they arise. ![Which branch should I target?](img/defaultbranch.png) -### Making changes after the PR was opened +### Making changes after the PR is open If you make the corrections we ask for in the same branch and push them to your fork again, the pull request automatically updates with the additional commit(s) so we can review it again. If all is well, we'll merge the code and your commits are forever part of Umbraco! ### Keeping your Umbraco fork in sync with the main repository -We recommend you sync with our repository before you submit your pull request. That way, you can fix any potential merge conflicts and make our lives a little bit easier. +We recommend you to sync with our repository before you submit your pull request. That way, you can fix any potential merge conflicts and make our lives a little bit easier. -Also, if you've submitted a pull request three weeks ago and want to work on something new, you'll want to get the latest code to build against of course. +Also, if you have submitted a pull request three weeks ago and want to work on something new, you'll want to get the latest code to build against of course. -To sync your fork with this original one, you'll have to add the upstream url, you only have to do this once: +To sync your fork with this original one, you'll have to add the upstream url. You only have to do this once: ``` git remote add upstream https://github.com/umbraco/Umbraco-CMS.git @@ -179,9 +184,13 @@ Then when you want to get the changes from the main repository: ``` git fetch upstream -git rebase upstream/v8/dev +git rebase upstream/v8/contrib ``` -In this command we're syncing with the `v8/dev` branch, but you can of course choose another one if needed. +In this command we're syncing with the `v8/contrib` branch, but you can of course choose another one if needed. (More info on how this works: [http://robots.thoughtbot.com/post/5133345960/keeping-a-git-fork-updated](http://robots.thoughtbot.com/post/5133345960/keeping-a-git-fork-updated)) + +### And finally + +We welcome all kinds of contributions to this repository. If you don't feel you'd like to make code changes here, you can visit our [documentation repository](https://github.com/umbraco/UmbracoDocs) and use your experience to contribute to making the docs we have, even better. We also encourage community members to feel free to comment on others' pull requests and issues - the expertise we have is not limited to the PR team and HQ. So, if you see something on the issue tracker or pull requests you feel you can add to, please don't be shy. diff --git a/.github/CONTRIBUTION_GUIDELINES.md b/.github/CONTRIBUTION_GUIDELINES.md new file mode 100644 index 0000000000..0ac35e6897 --- /dev/null +++ b/.github/CONTRIBUTION_GUIDELINES.md @@ -0,0 +1,35 @@ +# Contributing to Umbraco CMS + +When you’re considering creating a pull request for Umbraco CMS, we will categorize them in two different sizes, small and large. + +The process for both sizes is very similar, as [explained in the contribution document](CONTRIBUTING.md#how-do-i-begin). + +## Small PRs +Bug fixes and small improvements - can be recognized by seeing a small number of changes and possibly a small number of new files. + +We’re usually able to handle small PRs pretty quickly. A community volunteer will do the initial review and flag it for Umbraco HQ as “community tested”. If everything looks good, it will be merged pretty quickly [as per the described process](REVIEW_PROCESS.md). + +### Up for grabs + +Umbraco HQ will regularly mark newly created issues on the issue tracker with the `Up for grabs` tag. This means that the proposed changes are wanted in Umbraco but the HQ does not have the time to make them at this time. We encourage anyone to pick them up and help out. + +If you do start working on something, make sure to leave a small comment on the issue saying something like: "I'm working on this". That way other people stumbling upon the issue know they don't need to pick it up, someone already has. + +## Large PRs +New features and large refactorings - can be recognized by seeing a large number of changes, plenty of new files, updates to package manager files (NuGet’s packages.config, NPM’s packages.json, etc.). + +We would love to follow the same process for larger PRs but this is not always possible due to time limitations and priorities that need to be aligned. We don’t want to put up any barriers, but this document should set the correct expectations. + +Please make sure to describe your idea in an issue, it helps to put in mockup screenshots or videos. + +If the change makes sense for HQ to include in Umbraco CMS we will leave you some feedback on how we’d like to see it being implemented. + +If a larger pull request is encouraged by Umbraco HQ, the process will be similar to what is described in the [small PRs process](#small-prs) above, we strive to feedback within 14 days. Finalizing and merging the PR might take longer though as it will likely need to be picked up by the development team to make sure everything is in order. We’ll keep you posted on the progress. + +It is highly recommended that you speak to the HQ before making large, complex changes. + +### Pull request or package? + +If it doesn’t fit in CMS right now, we will likely encourage you to make it into a package instead. A package is a great way to check out popularity of a feature, learn how people use it, validate good usability and fix bugs. + +Eventually, a package could "graduate" to be included in the CMS. diff --git a/.github/ISSUE_TEMPLATE/1_Bug.md b/.github/ISSUE_TEMPLATE/1_Bug.md index 619452f700..d388af0d39 100644 --- a/.github/ISSUE_TEMPLATE/1_Bug.md +++ b/.github/ISSUE_TEMPLATE/1_Bug.md @@ -7,15 +7,15 @@ A brief description of the issue goes here. +## Umbraco version + +I am seeing this issue on Umbraco version: Reproduction diff --git a/.github/README.md b/.github/README.md index bdf9ef9f67..467ca6e5e6 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,4 +1,4 @@ -# [Umbraco CMS](https://umbraco.com) · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE.md) [![Build status](https://umbraco.visualstudio.com/Umbraco%20Cms/_apis/build/status/Cms%208%20Continuous?branchName=v8/dev)](https://umbraco.visualstudio.com/Umbraco%20Cms/_build?definitionId=75) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![pullreminders](https://pullreminders.com/badge.svg)](https://pullreminders.com?ref=badge) +# [Umbraco CMS](https://umbraco.com) · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE.md) [![Build status](https://umbraco.visualstudio.com/Umbraco%20Cms/_apis/build/status/Cms%208%20Continuous?branchName=v8/contrib)](https://umbraco.visualstudio.com/Umbraco%20Cms/_build?definitionId=75) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![Twitter](https://img.shields.io/twitter/follow/umbraco.svg?style=social&label=Follow)](https://twitter.com/intent/follow?screen_name=umbraco) Umbraco is the friendliest, most flexible and fastest growing ASP.NET CMS, and used by more than 500,000 websites worldwide. Our mission is to help you deliver delightful digital experiences by making Umbraco friendly, simpler and social. @@ -21,7 +21,7 @@ Please also see our [Code of Conduct](CODE_OF_CONDUCT.md). [Umbraco Cloud](https://umbraco.com/cloud) is the easiest and fastest way to use Umbraco yet, with full support for all your custom .NET code and integrations. You're up and running in less than a minute, and your life will be made easier with automated upgrades and a built-in deployment engine. We offer a free 14-day trial, no credit card needed. -If you want to DIY, you can [download Umbraco]((https://our.umbraco.com/download)) either as a ZIP file or via NuGet. It's the same version of Umbraco CMS that powers Umbraco Cloud, but you'll need to find a place to host it yourself, and handling deployments and upgrades will be all up to you. +If you want to DIY, then you can [download Umbraco]((https://our.umbraco.com/download)) either as a ZIP file or via NuGet. It's the same version of Umbraco CMS that powers Umbraco Cloud, but you'll need to find a place to host it yourself, and handling deployments and upgrades will be all up to you. ## Documentation @@ -29,7 +29,7 @@ The documentation for Umbraco CMS can be found [on Our Umbraco](https://our.umbr ## Join the Umbraco community -Our friendly community is available 24/7 at the community hub we call ["Our Umbraco"](https://our.umbraco.com/). Our Umbraco features forums for questions and answers, documentation, downloadable plugins for Umbraco, and a rich collection of community resources. +Our friendly community is available 24/7 at the community hub, we call ["Our Umbraco"](https://our.umbraco.com/). Our Umbraco features forums for questions and answers, documentation, downloadable plugins for Umbraco, and a rich collection of community resources. Besides "Our", we all support each other also via Twitter: [Umbraco HQ](https://twitter.com/umbraco), [Release Updates](https://twitter.com/umbracoproject), [#umbraco](https://twitter.com/hashtag/umbraco) diff --git a/.github/REVIEW_PROCESS.md b/.github/REVIEW_PROCESS.md new file mode 100644 index 0000000000..917d25b090 --- /dev/null +++ b/.github/REVIEW_PROCESS.md @@ -0,0 +1,25 @@ +# Review process + +You're an awesome person and have sent us your contribution in the form of a pull request! It's now time to relax for a bit and wait for our response. + +In order to set some expectations, here's what happens next. + +## Review process + +You will get an initial reply within 48 hours (workdays) to acknowledge that we’ve seen your PR and we’ll pick it up as soon as we can. + +You will get feedback within at most 14 days after opening the PR. You’ll most likely get feedback sooner though. Then there are a few possible outcomes: + +- Your proposed change is awesome! We merge it in and it will be included in the next minor release of Umbraco +- If the change is a high priority bug fix, we will cherry-pick it into the next patch release as well so that we can release it as soon as possible +- Your proposed change is awesome but needs a bit more work, we’ll give you feedback on the changes we’d like to see +- Your proposed change is awesome but.. not something we’re looking to include at this point. We’ll close your PR and the related issue (we’ll be nice about it!) + +## Are you still available? + +We understand you have other things to do and can't just drop everything to help us out. +So if we’re asking for your help to improve the PR we’ll wait for two weeks to give you a fair chance to make changes. We’ll ask for an update if we don’t hear back from you after that time. + +If we don’t hear back from you for 4 weeks, we’ll close the PR so that it doesn’t just hang around forever. You’re very welcome to re-open it once you have some more time to spend on it. + +There will be times that we really like your proposed changes and we’ll finish the final improvements we’d like to see ourselves. You still get the credits and your commits will live on in the git repository. \ No newline at end of file diff --git a/.github/img/defaultbranch.png b/.github/img/defaultbranch.png index f3a5b9efbc..3550b5c34c 100644 Binary files a/.github/img/defaultbranch.png and b/.github/img/defaultbranch.png differ diff --git a/.gitignore b/.gitignore index 585bd855b7..12ad3299ad 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ App_Data/TEMP/* src/Umbraco.Web.UI/[Cc]ss/* src/Umbraco.Web.UI/App_Code/* src/Umbraco.Web.UI/App_Data/* +src/Umbraco.Web.UI/data/* src/Umbraco.Tests/App_Data/* src/Umbraco.Web.UI/[Mm]edia/* src/Umbraco.Web.UI/[Mm]aster[Pp]ages/* @@ -132,7 +133,8 @@ src/Umbraco.Web.UI.Client/bower_components/* preserve.belle #Ignore Rule for output of generated documentation files from Grunt docserve -src/Umbraco.Web.UI.Client/docs/api +src/Umbraco.Web.UI.Docs/api +src/Umbraco.Web.UI.Docs/package-lock.json src/*.boltdata/ src/umbraco.sln.ide/* src/.vs/ diff --git a/build/NuSpecs/UmbracoCms.Web.nuspec b/build/NuSpecs/UmbracoCms.Web.nuspec index 4aa354eba2..347bde139e 100644 --- a/build/NuSpecs/UmbracoCms.Web.nuspec +++ b/build/NuSpecs/UmbracoCms.Web.nuspec @@ -25,10 +25,10 @@ not want this to happen as the alpha of the next major is, really, the next major already. --> - - + + - + @@ -52,14 +52,17 @@ + + + diff --git a/build/NuSpecs/UmbracoCms.nuspec b/build/NuSpecs/UmbracoCms.nuspec index b7bfaaff5b..a6b06d9964 100644 --- a/build/NuSpecs/UmbracoCms.nuspec +++ b/build/NuSpecs/UmbracoCms.nuspec @@ -26,11 +26,9 @@ not want this to happen as the alpha of the next major is, really, the next major already. --> - - diff --git a/build/NuSpecs/tools/Readme.txt b/build/NuSpecs/tools/Readme.txt index e40b0dbc7e..b0583a2b4d 100644 --- a/build/NuSpecs/tools/Readme.txt +++ b/build/NuSpecs/tools/Readme.txt @@ -1,11 +1,11 @@ - 888 - 888 -888 888 88888b.d88b. 88888b. 888d888 8888b. .d8888b .d88b. -888 888 888 "888 "88b 888 "88b 888P" "88b d88P" d88""88b -888 888 888 888 888 888 888 888 .d888888 888 888 888 -Y88b 888 888 888 888 888 d88P 888 888 888 Y88b. Y88..88P - "Y88888 888 888 888 88888P" 888 "Y888888 "Y8888P "Y88P" + 888 + 888 +888 888 88888b.d88b. 88888b. 888d888 8888b. .d8888b .d88b. +888 888 888 "888 "88b 888 "88b 888P" "88b d88P" d88""88b +888 888 888 888 888 888 888 888 .d888888 888 888 888 +Y88 88Y 888 888 888 888 d88P 888 888 888 Y88b. Y88..88P + "Y888P" 888 888 888 88888P" 888 "Y888888 "Y8888P "Y88P" ------------------------------------------------------------------ diff --git a/build/NuSpecs/tools/ReadmeUpgrade.txt b/build/NuSpecs/tools/ReadmeUpgrade.txt index df364c64ed..91d49d896c 100644 --- a/build/NuSpecs/tools/ReadmeUpgrade.txt +++ b/build/NuSpecs/tools/ReadmeUpgrade.txt @@ -1,30 +1,26 @@ - _ _ __ __ ____ _____ _____ ____ - | | | | \/ | _ \| __ \ /\ / ____/ __ \ - | | | | \ / | |_) | |__) | / \ | | | | | | - | | | | |\/| | _ <| _ / / /\ \| | | | | | - | |__| | | | | |_) | | \ \ / ____ | |___| |__| | - \____/|_| |_|____/|_| \_/_/ \_\_____\____/ + 888 + 888 +888 888 88888b.d88b. 88888b. 888d888 8888b. .d8888b .d88b. +888 888 888 "888 "88b 888 "88b 888P" "88b d88P" d88""88b +888 888 888 888 888 888 888 888 .d888888 888 888 888 +Y88 88Y 888 888 888 888 d88P 888 888 888 Y88b. Y88..88P + "Y888P" 888 888 888 88888P" 888 "Y888888 "Y8888P "Y88P" ----------------------------------------------------- +------------------------------------------------------------------ Don't forget to build! -We've done our best to transform your configuration files but in case something is not quite right: remember we -backed up your files in App_Data\NuGetBackup so you can find the original files before they were transformed. - -We've overwritten all the files in the Umbraco folder, these have been backed up in -App_Data\NuGetBackup. We didn't overwrite the UI.xml file nor did we remove any files or folders that you or -a package might have added. Only the existing files were overwritten. If you customized anything then make -sure to do a compare and merge with the NuGetBackup folder. +We've done our best to transform your configuration files but in case something is not quite right: we recommmend you look in source control for the previous version so you can find the original files before they were transformed. This NuGet package includes build targets that extend the creation of a deploy package, which is generated by Publishing from Visual Studio. The targets will only work once Publishing is configured, so if you don't use Publish this won't affect you. + The following items will now be automatically included when creating a deploy package or publishing to the file system: umbraco, config\splashes and global.asax. Please read the release notes on our.umbraco.com: -http://our.umbraco.com/contribute/releases +https://our.umbraco.com/contribute/releases - Umbraco diff --git a/build/NuSpecs/tools/Web.config.install.xdt b/build/NuSpecs/tools/Web.config.install.xdt index 14778e0f10..2b79f95c70 100644 --- a/build/NuSpecs/tools/Web.config.install.xdt +++ b/build/NuSpecs/tools/Web.config.install.xdt @@ -53,7 +53,7 @@ - + @@ -76,7 +76,7 @@ - + @@ -128,6 +128,7 @@ + diff --git a/build/build-docs.ps1 b/build/build-docs.ps1 deleted file mode 100644 index 8cd3f090c7..0000000000 --- a/build/build-docs.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -$uenv=build/build.ps1 -get - -$src = "$($uenv.SolutionRoot)\src" -$tmp = $uenv.BuildTemp -$out = $uenv.BuildOutput -$DocFxJson = "$src\ApiDocs\docfx.json" -$DocFxSiteOutput = "$tmp\_site\*.*" - -################ Do the UI docs -$uenv.CompileBelle() - -"Moving to Umbraco.Web.UI.Client folder" -cd .\src\Umbraco.Web.UI.Client - -"Generating the docs and waiting before executing the next commands" -& gulp docs | Out-Null - -# change baseUrl -$BaseUrl = "https://our.umbraco.com/apidocs/v8/ui/" -$IndexPath = "./docs/api/index.html" -(Get-Content $IndexPath).replace('location.href.replace(rUrl, indexFile)', "`'" + $BaseUrl + "`'") | Set-Content $IndexPath - -# zip it -& $uenv.BuildEnv.Zip a -tzip -r "$out\ui-docs.zip" "$src\Umbraco.Web.UI.Client\docs\api\*.*" - - -################ Do the c# docs - -# Build the solution in debug mode -$SolutionPath = Join-Path -Path $src -ChildPath "umbraco.sln" -#$uenv.CompileUmbraco() - -#restore nuget packages -$uenv.RestoreNuGet() - -# run DocFx -$DocFx = $uenv.BuildEnv.DocFx - -Write-Host "$DocFxJson" -& $DocFx metadata $DocFxJson -& $DocFx build $DocFxJson - -# zip it -& $uenv.BuildEnv.Zip a -tzip -r "$out\csharp-docs.zip" $DocFxSiteOutput diff --git a/build/build.ps1 b/build/build.ps1 index e4994d2c4a..ea07e4516f 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -458,23 +458,22 @@ Write-Host "Prepare Angular Documentation" $src = "$($this.SolutionRoot)\src" - $out = $this.BuildOutput + $out = $this.BuildOutput - $this.CompileBelle() - - "Moving to Umbraco.Web.UI.Client folder" - cd .\src\Umbraco.Web.UI.Client + "Moving to Umbraco.Web.UI.Docs folder" + cd ..\src\Umbraco.Web.UI.Docs "Generating the docs and waiting before executing the next commands" - & gulp docs | Out-Null + & npm install + & npx gulp docs # change baseUrl $BaseUrl = "https://our.umbraco.com/apidocs/v8/ui/" - $IndexPath = "./docs/api/index.html" + $IndexPath = "./api/index.html" (Get-Content $IndexPath).replace('location.href.replace(rUrl, indexFile)', "`'" + $BaseUrl + "`'") | Set-Content $IndexPath # zip it - & $this.BuildEnv.Zip a -tzip -r "$out\ui-docs.zip" "$src\Umbraco.Web.UI.Client\docs\api\*.*" + & $this.BuildEnv.Zip a -tzip -r "$out\ui-docs.zip" "$src\Umbraco.Web.UI.Docs\api\*.*" }) $ubuild.DefineMethod("Build", diff --git a/src/ApiDocs/umbracotemplate/styles/main.css b/src/ApiDocs/umbracotemplate/styles/main.css index d74d51b150..802d42bc0a 100644 --- a/src/ApiDocs/umbracotemplate/styles/main.css +++ b/src/ApiDocs/umbracotemplate/styles/main.css @@ -1,65 +1,11 @@ body { - color: rgba(0,0,0,.8); -} -.navbar-inverse { - background: #a3db78; -} -.navbar-inverse .navbar-nav>li>a, .navbar-inverse .navbar-text { - color: rgba(0,0,0,.8); -} - -.navbar-inverse { - border-color: transparent; -} - -.sidetoc { - background-color: #f5fbf1; -} -body .toc { - background-color: #f5fbf1; -} -.sidefilter { - background-color: #daf0c9; -} -.subnav { - background-color: #f5fbf1; -} - -.navbar-inverse .navbar-nav>.active>a { - color: rgba(0,0,0,.8); - background-color: #daf0c9; -} - -.navbar-inverse .navbar-nav>.active>a:focus, .navbar-inverse .navbar-nav>.active>a:hover { - color: rgba(0,0,0,.8); - background-color: #daf0c9; -} - -.btn-primary { - color: rgba(0,0,0,.8); - background-color: #fff; - border-color: rgba(0,0,0,.8); -} -.btn-primary:hover { - background-color: #daf0c9; - color: rgba(0,0,0,.8); - border-color: rgba(0,0,0,.8); -} - -.toc .nav > li > a { - color: rgba(0,0,0,.8); -} - -button, a { - color: #f36f21; -} - -button:hover, -button:focus, -a:hover, -a:focus { - color: #143653; - text-decoration: none; + color: #34393e; + font-family: 'Roboto', sans-serif; + line-height: 1.5; + font-size: 16px; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + word-wrap: break-word } .navbar-header .navbar-brand { @@ -68,6 +14,202 @@ a:focus { width:50px; } -.toc .nav > li.active > a { - color: #f36f21; +#_content>a { + margin-top: 5px; } +/* HEADINGS */ + +h1 { + font-weight: 600; + font-size: 32px; +} + +h2 { + font-weight: 600; + font-size: 24px; + line-height: 1.8; +} + +h3 { + font-weight: 600; + font-size: 20px; + line-height: 1.8; +} + +h5 { + font-size: 14px; + padding: 10px 0px; +} + +article h1, +article h2, +article h3, +article h4 { + margin-top: 35px; + margin-bottom: 15px; +} + +article h4 { + padding-bottom: 8px; + border-bottom: 2px solid #ddd; +} + +/* NAVBAR */ + +.navbar-brand>img { + color: #fff; +} + +.navbar { + border: none; + /* Both navbars use box-shadow */ + -webkit-box-shadow: 0px 1px 3px 0px rgba(100, 100, 100, 0.5); + -moz-box-shadow: 0px 1px 3px 0px rgba(100, 100, 100, 0.5); + box-shadow: 0px 1px 3px 0px rgba(100, 100, 100, 0.5); +} + +.subnav { + border-top: 1px solid #ddd; + background-color: #fff; +} + +.navbar-inverse { + background-color: #303ea1; + z-index: 100; +} + +.navbar-inverse .navbar-nav>li>a, +.navbar-inverse .navbar-text { + color: #fff; + background-color: #303ea1; + border-bottom: 3px solid transparent; + padding-bottom: 12px; +} + +.navbar-inverse .navbar-nav>li>a:focus, +.navbar-inverse .navbar-nav>li>a:hover { + color: #fff; + background-color: #303ea1; + border-bottom: 3px solid white; +} + +.navbar-inverse .navbar-nav>.active>a, +.navbar-inverse .navbar-nav>.active>a:focus, +.navbar-inverse .navbar-nav>.active>a:hover { + color: #fff; + background-color: #303ea1; + border-bottom: 3px solid white; +} + +.navbar-form .form-control { + border: none; + border-radius: 20px; +} + +/* SIDEBAR */ + +.toc .level1>li { + font-weight: 400; +} + +.toc .nav>li>a { + color: #34393e; +} + +.sidefilter { + background-color: #fff; + border-left: none; + border-right: none; +} + +.sidefilter { + background-color: #fff; + border-left: none; + border-right: none; +} + +.toc-filter { + padding: 10px; + margin: 0; +} + +.toc-filter>input { + border: 2px solid #ddd; + border-radius: 20px; +} + +.toc-filter>.filter-icon { + display: none; +} + +.sidetoc>.toc { + background-color: #fff; + overflow-x: hidden; +} + +.sidetoc { + background-color: #fff; + border: none; +} + +/* ALERTS */ + +.alert { + padding: 0px 0px 5px 0px; + color: inherit; + background-color: inherit; + border: none; + box-shadow: 0px 2px 2px 0px rgba(100, 100, 100, 0.4); +} + +.alert>p { + margin-bottom: 0; + padding: 5px 10px; +} + +.alert>ul { + margin-bottom: 0; + padding: 5px 40px; +} + +.alert>h5 { + padding: 10px 15px; + margin-top: 0; + text-transform: uppercase; + font-weight: bold; + border-radius: 4px 4px 0 0; +} + +.alert-info>h5 { + color: #1976d2; + border-bottom: 4px solid #1976d2; + background-color: #e3f2fd; +} + +.alert-warning>h5 { + color: #f57f17; + border-bottom: 4px solid #f57f17; + background-color: #fff3e0; +} + +.alert-danger>h5 { + color: #d32f2f; + border-bottom: 4px solid #d32f2f; + background-color: #ffebee; +} + +/* CODE HIGHLIGHT */ +pre { + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + word-break: break-all; + word-wrap: break-word; + background-color: #fffaef; + border-radius: 4px; + box-shadow: 0px 1px 4px 1px rgba(100, 100, 100, 0.4); +} + +.sideaffix { + overflow: visible; +} \ No newline at end of file diff --git a/src/SolutionInfo.cs b/src/SolutionInfo.cs index a4e859988e..a0d969c1b8 100644 --- a/src/SolutionInfo.cs +++ b/src/SolutionInfo.cs @@ -18,5 +18,5 @@ using System.Resources; [assembly: AssemblyVersion("8.0.0")] // these are FYI and changed automatically -[assembly: AssemblyFileVersion("8.2.0")] -[assembly: AssemblyInformationalVersion("8.2.0")] +[assembly: AssemblyFileVersion("8.7.0")] +[assembly: AssemblyInformationalVersion("8.7.0")] diff --git a/src/Umbraco.Core/Cache/CacheKeys.cs b/src/Umbraco.Core/Cache/CacheKeys.cs index e8f93d636a..b8ee0e97c4 100644 --- a/src/Umbraco.Core/Cache/CacheKeys.cs +++ b/src/Umbraco.Core/Cache/CacheKeys.cs @@ -11,5 +11,6 @@ public const string TemplateFrontEndCacheKey = "template"; public const string MacroContentCacheKey = "macroContent_"; // used in MacroRenderers + public const string MacroFromAliasCacheKey = "macroFromAlias_"; } } diff --git a/src/Umbraco.Core/Collections/ConcurrentHashSet.cs b/src/Umbraco.Core/Collections/ConcurrentHashSet.cs index 54367ed588..c4dba51acd 100644 --- a/src/Umbraco.Core/Collections/ConcurrentHashSet.cs +++ b/src/Umbraco.Core/Collections/ConcurrentHashSet.cs @@ -70,7 +70,23 @@ namespace Umbraco.Core.Collections /// The number of elements contained in the . /// /// 2 - public int Count => GetThreadSafeClone().Count; + public int Count + { + get + { + try + { + _instanceLocker.EnterReadLock(); + return _innerSet.Count; + } + finally + { + if (_instanceLocker.IsReadLockHeld) + _instanceLocker.ExitReadLock(); + } + + } + } /// /// Gets a value indicating whether the is read-only. @@ -105,8 +121,7 @@ namespace Umbraco.Core.Collections /// public bool TryAdd(T item) { - var clone = GetThreadSafeClone(); - if (clone.Contains(item)) return false; + if (Contains(item)) return false; try { _instanceLocker.EnterWriteLock(); @@ -150,7 +165,16 @@ namespace Umbraco.Core.Collections /// The object to locate in the . public bool Contains(T item) { - return GetThreadSafeClone().Contains(item); + try + { + _instanceLocker.EnterReadLock(); + return _innerSet.Contains(item); + } + finally + { + if (_instanceLocker.IsReadLockHeld) + _instanceLocker.ExitReadLock(); + } } /// @@ -168,13 +192,13 @@ namespace Umbraco.Core.Collections HashSet clone = null; try { - _instanceLocker.EnterWriteLock(); + _instanceLocker.EnterReadLock(); clone = new HashSet(_innerSet, _innerSet.Comparer); } finally { - if (_instanceLocker.IsWriteLockHeld) - _instanceLocker.ExitWriteLock(); + if (_instanceLocker.IsReadLockHeld) + _instanceLocker.ExitReadLock(); } return clone; } diff --git a/src/Umbraco.Core/Collections/ObservableDictionary.cs b/src/Umbraco.Core/Collections/ObservableDictionary.cs index 40269aa4eb..c6aedab377 100644 --- a/src/Umbraco.Core/Collections/ObservableDictionary.cs +++ b/src/Umbraco.Core/Collections/ObservableDictionary.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Runtime.Serialization; namespace Umbraco.Core.Collections { @@ -26,7 +27,7 @@ namespace Umbraco.Core.Collections /// The equality comparer to use when comparing keys, or null to use the default comparer. public ObservableDictionary(Func keySelector, IEqualityComparer equalityComparer = null) { - KeySelector = keySelector ?? throw new ArgumentException("keySelector"); + KeySelector = keySelector ?? throw new ArgumentException(nameof(keySelector)); Indecies = new Dictionary(equalityComparer); } @@ -36,7 +37,7 @@ namespace Umbraco.Core.Collections { var key = KeySelector(item); if (Indecies.ContainsKey(key)) - throw new DuplicateKeyException(key.ToString()); + throw new ArgumentException($"An element with the same key '{key}' already exists in the dictionary.", nameof(item)); if (index != Count) { @@ -91,7 +92,7 @@ namespace Umbraco.Core.Collections { //confirm key matches if (!KeySelector(value).Equals(key)) - throw new InvalidOperationException("Key of new value does not match"); + throw new InvalidOperationException("Key of new value does not match."); if (!Indecies.ContainsKey(key)) { @@ -118,7 +119,7 @@ namespace Umbraco.Core.Collections //confirm key matches if (!KeySelector(value).Equals(key)) - throw new InvalidOperationException("Key of new value does not match"); + throw new InvalidOperationException("Key of new value does not match."); this[Indecies[key]] = value; return true; @@ -155,12 +156,12 @@ namespace Umbraco.Core.Collections { if (!Indecies.ContainsKey(currentKey)) { - throw new InvalidOperationException("No item with the key " + currentKey + "was found in the collection"); + throw new InvalidOperationException($"No item with the key '{currentKey}' was found in the dictionary."); } if (ContainsKey(newKey)) { - throw new DuplicateKeyException(newKey.ToString()); + throw new ArgumentException($"An element with the same key '{newKey}' already exists in the dictionary.", nameof(newKey)); } var currentIndex = Indecies[currentKey]; @@ -234,16 +235,5 @@ namespace Umbraco.Core.Collections } #endregion - - internal class DuplicateKeyException : Exception - { - public DuplicateKeyException(string key) - : base("Attempted to insert duplicate key \"" + key + "\" in collection.") - { - Key = key; - } - - public string Key { get; } - } } } diff --git a/src/Umbraco.Core/Compose/RelateOnCopyComponent.cs b/src/Umbraco.Core/Compose/RelateOnCopyComponent.cs index 63a7e170da..b56ff8b87e 100644 --- a/src/Umbraco.Core/Compose/RelateOnCopyComponent.cs +++ b/src/Umbraco.Core/Compose/RelateOnCopyComponent.cs @@ -26,10 +26,11 @@ namespace Umbraco.Core.Compose if (relationType == null) { - relationType = new RelationType(Constants.ObjectTypes.Document, + relationType = new RelationType(Constants.Conventions.RelationTypes.RelateDocumentOnCopyAlias, + Constants.Conventions.RelationTypes.RelateDocumentOnCopyName, + true, Constants.ObjectTypes.Document, - Constants.Conventions.RelationTypes.RelateDocumentOnCopyAlias, - Constants.Conventions.RelationTypes.RelateDocumentOnCopyName) { IsBidirectional = true }; + Constants.ObjectTypes.Document); relationService.Save(relationType); } diff --git a/src/Umbraco.Core/Compose/RelateOnTrashComponent.cs b/src/Umbraco.Core/Compose/RelateOnTrashComponent.cs index 8371f9b279..4e01c50fc6 100644 --- a/src/Umbraco.Core/Compose/RelateOnTrashComponent.cs +++ b/src/Umbraco.Core/Compose/RelateOnTrashComponent.cs @@ -63,7 +63,7 @@ namespace Umbraco.Core.Compose var documentObjectType = Constants.ObjectTypes.Document; const string relationTypeName = Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteName; - relationType = new RelationType(documentObjectType, documentObjectType, relationTypeAlias, relationTypeName); + relationType = new RelationType(relationTypeName, relationTypeAlias, false, documentObjectType, documentObjectType); relationService.Save(relationType); } @@ -106,7 +106,7 @@ namespace Umbraco.Core.Compose { var documentObjectType = Constants.ObjectTypes.Document; const string relationTypeName = Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteName; - relationType = new RelationType(documentObjectType, documentObjectType, relationTypeAlias, relationTypeName); + relationType = new RelationType(relationTypeName, relationTypeAlias, false, documentObjectType, documentObjectType); relationService.Save(relationType); } foreach (var item in e.MoveInfoCollection) diff --git a/src/Umbraco.Core/Composing/ComponentCollection.cs b/src/Umbraco.Core/Composing/ComponentCollection.cs index 9b5319dc41..62b240f10f 100644 --- a/src/Umbraco.Core/Composing/ComponentCollection.cs +++ b/src/Umbraco.Core/Composing/ComponentCollection.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Umbraco.Core.Logging; @@ -43,8 +44,15 @@ namespace Umbraco.Core.Composing var componentType = component.GetType(); using (_logger.DebugDuration($"Terminating {componentType.FullName}.", $"Terminated {componentType.FullName}.", thresholdMilliseconds: LogThresholdMilliseconds)) { - component.Terminate(); - component.DisposeIfDisposable(); + try + { + component.Terminate(); + component.DisposeIfDisposable(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error while terminating component {ComponentType}.", componentType.FullName); + } } } } diff --git a/src/Umbraco.Core/Composing/Composers.cs b/src/Umbraco.Core/Composing/Composers.cs index 0510740e42..b2e6c9d068 100644 --- a/src/Umbraco.Core/Composing/Composers.cs +++ b/src/Umbraco.Core/Composing/Composers.cs @@ -18,19 +18,52 @@ namespace Umbraco.Core.Composing private readonly Composition _composition; private readonly IProfilingLogger _logger; private readonly IEnumerable _composerTypes; + private readonly IEnumerable _enableDisableAttributes; private const int LogThresholdMilliseconds = 100; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The composition. - /// The composer types. - /// A profiling logger. + /// The types. + /// The profiling logger. + [Obsolete("This overload only gets the EnableComposer/DisableComposer attributes from the composerTypes assemblies.")] public Composers(Composition composition, IEnumerable composerTypes, IProfilingLogger logger) + : this(composition, composerTypes, Enumerable.Empty(), logger) + { + var enableDisableAttributes = new List(); + + var assemblies = composerTypes.Select(t => t.Assembly).Distinct(); + foreach (var assembly in assemblies) + { + enableDisableAttributes.AddRange(assembly.GetCustomAttributes(typeof(EnableComposerAttribute))); + enableDisableAttributes.AddRange(assembly.GetCustomAttributes(typeof(DisableComposerAttribute))); + } + + _enableDisableAttributes = enableDisableAttributes; + } + + /// + /// Initializes a new instance of the class. + /// + /// The composition. + /// The types. + /// The and/or attributes. + /// The profiling logger. + /// composition + /// or + /// composerTypes + /// or + /// enableDisableAttributes + /// or + /// logger + + public Composers(Composition composition, IEnumerable composerTypes, IEnumerable enableDisableAttributes, IProfilingLogger logger) { _composition = composition ?? throw new ArgumentNullException(nameof(composition)); _composerTypes = composerTypes ?? throw new ArgumentNullException(nameof(composerTypes)); + _enableDisableAttributes = enableDisableAttributes ?? throw new ArgumentNullException(nameof(enableDisableAttributes)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -103,7 +136,7 @@ namespace Umbraco.Core.Composing .ToList(); // enable or disable composers - EnableDisableComposers(composerTypeList); + EnableDisableComposers(_enableDisableAttributes, composerTypeList); void GatherInterfaces(Type type, Func getTypeInAttribute, HashSet iset, List set2) where TAttribute : Attribute @@ -218,7 +251,7 @@ namespace Umbraco.Core.Composing return text.ToString(); } - private static void EnableDisableComposers(ICollection types) + private static void EnableDisableComposers(IEnumerable enableDisableAttributes, ICollection types) { var enabled = new Dictionary(); @@ -240,20 +273,16 @@ namespace Umbraco.Core.Composing enableInfo.Weight = weight2; } - var assemblies = types.Select(x => x.Assembly).Distinct(); - foreach (var assembly in assemblies) + foreach (var attr in enableDisableAttributes.OfType()) { - foreach (var attr in assembly.GetCustomAttributes()) - { - var type = attr.EnabledType; - UpdateEnableInfo(type, 2, enabled, true); - } + var type = attr.EnabledType; + UpdateEnableInfo(type, 2, enabled, true); + } - foreach (var attr in assembly.GetCustomAttributes()) - { - var type = attr.DisabledType; - UpdateEnableInfo(type, 2, enabled, false); - } + foreach (var attr in enableDisableAttributes.OfType()) + { + var type = attr.DisabledType; + UpdateEnableInfo(type, 2, enabled, false); } foreach (var composerType in types) diff --git a/src/Umbraco.Core/Composing/CompositionExtensions/FileSystems.cs b/src/Umbraco.Core/Composing/CompositionExtensions/FileSystems.cs index 078a505be9..8518d907b5 100644 --- a/src/Umbraco.Core/Composing/CompositionExtensions/FileSystems.cs +++ b/src/Umbraco.Core/Composing/CompositionExtensions/FileSystems.cs @@ -90,7 +90,7 @@ namespace Umbraco.Core.Composing.CompositionExtensions // register the IFileSystem supporting the IMediaFileSystem // THIS IS THE ONLY THING THAT NEEDS TO CHANGE, IN ORDER TO REPLACE THE UNDERLYING FILESYSTEM // and, SupportingFileSystem.For() returns the underlying filesystem - composition.SetMediaFileSystem(() => new PhysicalFileSystem("~/media")); + composition.SetMediaFileSystem(() => new PhysicalFileSystem(SystemDirectories.Media)); return composition; } diff --git a/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs b/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs index 0baefe104b..d252c58730 100644 --- a/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs +++ b/src/Umbraco.Core/Composing/CompositionExtensions/Services.cs @@ -96,7 +96,7 @@ namespace Umbraco.Core.Composing.CompositionExtensions var pluginLangFolders = appPlugins.Exists == false ? Enumerable.Empty() : appPlugins.GetDirectories() - .SelectMany(x => x.GetDirectories("Lang")) + .SelectMany(x => x.GetDirectories("Lang", SearchOption.AllDirectories)) .SelectMany(x => x.GetFiles("*.xml", SearchOption.TopDirectoryOnly)) .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, false)); diff --git a/src/Umbraco.Core/Composing/Current.cs b/src/Umbraco.Core/Composing/Current.cs index f12bf0dd63..846331a16e 100644 --- a/src/Umbraco.Core/Composing/Current.cs +++ b/src/Umbraco.Core/Composing/Current.cs @@ -154,6 +154,9 @@ namespace Umbraco.Core.Composing public static DataEditorCollection DataEditors => Factory.GetInstance(); + public static DataValueReferenceFactoryCollection DataValueReferenceFactories + => Factory.GetInstance(); + public static PropertyEditorCollection PropertyEditors => Factory.GetInstance(); diff --git a/src/Umbraco.Core/Composing/Lifetime.cs b/src/Umbraco.Core/Composing/Lifetime.cs index 1a2cc3119a..012555be5e 100644 --- a/src/Umbraco.Core/Composing/Lifetime.cs +++ b/src/Umbraco.Core/Composing/Lifetime.cs @@ -12,31 +12,62 @@ /// or MS.DI, PerDependency in Autofac. Transient, + // TODO: We need to fix this up, currently LightInject is the only one that behaves differently from all other containers. + // ... the simple fix would be to map this to PerScopeLifetime in LI but need to wait on a response here https://github.com/seesharper/LightInject/issues/494#issuecomment-518942625 + // + // we use it for controllers, httpContextBase and other request scoped objects: MembershpHelper, TagQuery, UmbracoTreeSearcher and ISearchableTree + // - so that they are automatically disposed at the end of the scope (ie request) + // - not sure they should not be simply 'scoped'? + /// /// One unique instance per request. /// - // TODO: review lifetimes for LightInject vs other containers - // currently, corresponds to 'Request' in LightInject which is 'Transient + disposed by Scope' - // but NOT (in LightInject) a per-web-request lifetime, more a TransientScoped - // - // we use it for controllers, httpContextBase and umbracoContext - // - so that they are automatically disposed at the end of the scope (ie request) - // - not sure they should not be simply 'scoped'? - // - // Castle has an extra PerWebRequest something, and others use scope - // what about Request before first request ie during application startup? - // see http://blog.ploeh.dk/2009/11/17/UsingCastleWindsor'sPerWebRequestlifestylewithASP.NETMVConIIS7/ - // Castle ends up requiring a special scope manager too - // see https://groups.google.com/forum/#!topic/castle-project-users/1E2W9LVIYR4 - // - // but maybe also - why are we requiring scoped services at startup? + /// + /// + /// Any instance created with this lifetime will be disposed at the end of a request. + /// + /// Corresponds to + /// + /// PerRequestLifeTime in LightInject - means transient but disposed at the end of the current web request. + /// see: https://github.com/seesharper/LightInject/issues/494#issuecomment-518493262 + /// + /// + /// Scoped in MS.DI - means one per web request. + /// see https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-2.2#service-lifetimes + /// + /// InstancePerRequest in Autofac - means one per web request. + /// see https://autofaccn.readthedocs.io/en/latest/lifetime/instance-scope.html#instance-per-request + /// But "Behind the scenes, though, it’s still just instance per matching lifetime scope." + /// + /// + /// LifestylePerWebRequest in Castle Windsor - means one per web request. + /// see https://github.com/castleproject/Windsor/blob/master/docs/mvc-tutorial-part-7-lifestyles.md#the-perwebrequest-lifestyle + /// + /// Request, /// - /// One unique instance per container scope. + /// One unique instance per scope. /// - /// Corresponds to Scope in LightInject, Scoped in MS.DI - /// or Castle Windsor, PerLifetimeScope in Autofac. + /// + /// + /// Any instance created with this lifetime will be disposed at the end of the current scope. + /// + /// Corresponds to + /// PerScopeLifetime in LightInject (when in a request, means one per web request) + /// + /// Scoped in MS.DI (when in a request, means one per web request) + /// see https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-2.2#service-lifetimes + /// + /// InstancePerLifetimeScope in Autofac (when in a request, means one per web request) + /// see https://autofaccn.readthedocs.io/en/latest/lifetime/instance-scope.html#instance-per-lifetime-scope + /// Also note that Autofac's InstancePerRequest is the same as this, see https://autofaccn.readthedocs.io/en/latest/lifetime/instance-scope.html#instance-per-request + /// it says "Behind the scenes, though, it’s still just instance per matching lifetime scope." + /// + /// + /// LifestyleScoped in Castle Windsor + /// + /// Scope, /// diff --git a/src/Umbraco.Core/Composing/LightInject/LightInjectException.cs b/src/Umbraco.Core/Composing/LightInject/LightInjectException.cs index fa0aed21ca..e1344468f9 100644 --- a/src/Umbraco.Core/Composing/LightInject/LightInjectException.cs +++ b/src/Umbraco.Core/Composing/LightInject/LightInjectException.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.Serialization; using System.Text; namespace Umbraco.Core.Composing.LightInject @@ -6,20 +7,51 @@ namespace Umbraco.Core.Composing.LightInject /// /// Represents errors that occur due to LightInject. /// + /// + [Serializable] public class LightInjectException : Exception { - public LightInjectException(string message) - : base(message) - { } - - public LightInjectException(string message, Exception innerException) - : base(message, innerException) - { } - private const string LightInjectUnableToResolveType = "Unable to resolve type:"; private const string LightInjectUnresolvedDependency = "Unresolved dependency "; private const string LightInjectRequestedDependency = "[Requested dependency:"; + /// + /// Initializes a new instance of the class. + /// + public LightInjectException() + { } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public LightInjectException(string message) + : base(message) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public LightInjectException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected LightInjectException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + + /// + /// Tries to throw the exception with additional details. + /// + /// The exception. + /// public static void TryThrow(Exception e) { var ex = e as InvalidOperationException; @@ -32,6 +64,12 @@ namespace Umbraco.Core.Composing.LightInject throw new LightInjectException(sb.ToString(), e); } + /// + /// Tries to throw the exception with additional details. + /// + /// The exception. + /// The implementing type. + /// public static void TryThrow(Exception e, Type implementingType) { var ex = e as InvalidOperationException; @@ -45,6 +83,11 @@ namespace Umbraco.Core.Composing.LightInject throw new LightInjectException(sb.ToString(), e); } + /// + /// Writes the details. + /// + /// The exception. + /// The to write the details to. private static void WriteDetails(InvalidOperationException ex, StringBuilder sb) { ex = ex.InnerException as InvalidOperationException; diff --git a/src/Umbraco.Core/Composing/TypeLoader.cs b/src/Umbraco.Core/Composing/TypeLoader.cs index fe7a561eca..6d0b1a0514 100644 --- a/src/Umbraco.Core/Composing/TypeLoader.cs +++ b/src/Umbraco.Core/Composing/TypeLoader.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.Serialization; using System.Text; using System.Threading; using System.Web; @@ -505,6 +506,49 @@ namespace Umbraco.Core.Composing #endregion + #region Get Assembly Attributes + + /// + /// Gets the assembly attributes of the specified type . + /// + /// The attribute type. + /// + /// The assembly attributes of the specified type . + /// + public IEnumerable GetAssemblyAttributes() + where T : Attribute + { + return AssembliesToScan.SelectMany(a => a.GetCustomAttributes()).ToList(); + } + + /// + /// Gets all the assembly attributes. + /// + /// + /// All assembly attributes. + /// + public IEnumerable GetAssemblyAttributes() + { + return AssembliesToScan.SelectMany(a => a.GetCustomAttributes()).ToList(); + } + + /// + /// Gets the assembly attributes of the specified . + /// + /// The attribute types. + /// + /// The assembly attributes of the specified types. + /// + /// attributeTypes + public IEnumerable GetAssemblyAttributes(params Type[] attributeTypes) + { + if (attributeTypes == null) throw new ArgumentNullException(nameof(attributeTypes)); + + return AssembliesToScan.SelectMany(a => attributeTypes.SelectMany(at => a.GetCustomAttributes(at))).ToList(); + } + + #endregion + #region Get Types /// @@ -813,11 +857,44 @@ namespace Umbraco.Core.Composing } /// - /// Represents the error that occurs when a type was not found in the cache type - /// list with the specified TypeResolutionKind. + /// Represents the error that occurs when a type was not found in the cache type list with the specified TypeResolutionKind. /// + /// + [Serializable] internal class CachedTypeNotFoundInFileException : Exception - { } + { + /// + /// Initializes a new instance of the class. + /// + public CachedTypeNotFoundInFileException() + { } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public CachedTypeNotFoundInFileException(string message) + : base(message) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public CachedTypeNotFoundInFileException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected CachedTypeNotFoundInFileException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } #endregion } diff --git a/src/Umbraco.Core/CompositionExtensions.cs b/src/Umbraco.Core/CompositionExtensions.cs index 5dd33c2a60..ced9a9386a 100644 --- a/src/Umbraco.Core/CompositionExtensions.cs +++ b/src/Umbraco.Core/CompositionExtensions.cs @@ -49,6 +49,13 @@ namespace Umbraco.Core public static DataEditorCollectionBuilder DataEditors(this Composition composition) => composition.WithCollectionBuilder(); + /// + /// Gets the data value reference factory collection builder. + /// + /// The composition. + public static DataValueReferenceFactoryCollectionBuilder DataValueReferenceFactories(this Composition composition) + => composition.WithCollectionBuilder(); + /// /// Gets the property value converters collection builder. /// diff --git a/src/Umbraco.Core/Configuration/GlobalSettings.cs b/src/Umbraco.Core/Configuration/GlobalSettings.cs index 49f4481a59..a888e3c42b 100644 --- a/src/Umbraco.Core/Configuration/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/GlobalSettings.cs @@ -33,7 +33,6 @@ namespace Umbraco.Core.Configuration /// private static void ResetInternal() { - GlobalSettingsExtensions.Reset(); _reservedPaths = null; _reservedUrls = null; HasSmtpServer = null; diff --git a/src/Umbraco.Core/Configuration/GlobalSettingsExtensions.cs b/src/Umbraco.Core/Configuration/GlobalSettingsExtensions.cs index 6bfb7ea904..bc76caacee 100644 --- a/src/Umbraco.Core/Configuration/GlobalSettingsExtensions.cs +++ b/src/Umbraco.Core/Configuration/GlobalSettingsExtensions.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Web; using System.Web.Routing; using Umbraco.Core.IO; @@ -9,22 +11,9 @@ namespace Umbraco.Core.Configuration { public static class GlobalSettingsExtensions { - /// - /// Used in unit testing to reset all config items, this is automatically called by GlobalSettings.Reset() - /// - internal static void Reset() - { - _reservedUrlsCache = null; - _mvcArea = null; - } - - private static readonly object Locker = new object(); - //make this volatile so that we can ensure thread safety with a double check lock - private static volatile string _reservedUrlsCache; - private static string _reservedPathsCache; - private static HashSet _reservedList = new HashSet(); private static string _mvcArea; + /// /// This returns the string of the MVC Area route. /// @@ -40,6 +29,13 @@ namespace Umbraco.Core.Configuration { if (_mvcArea != null) return _mvcArea; + _mvcArea = GetUmbracoMvcAreaNoCache(globalSettings); + + return _mvcArea; + } + + internal static string GetUmbracoMvcAreaNoCache(this IGlobalSettings globalSettings) + { if (globalSettings.Path.IsNullOrWhiteSpace()) { throw new InvalidOperationException("Cannot create an MVC Area path without the umbracoPath specified"); @@ -48,95 +44,8 @@ namespace Umbraco.Core.Configuration var path = globalSettings.Path; if (path.StartsWith(SystemDirectories.Root)) // beware of TrimStart, see U4-2518 path = path.Substring(SystemDirectories.Root.Length); - _mvcArea = path.TrimStart('~').TrimStart('/').Replace('/', '-').Trim().ToLower(); - return _mvcArea; + return path.TrimStart('~').TrimStart('/').Replace('/', '-').Trim().ToLower(); } - /// - /// Determines whether the specified URL is reserved or is inside a reserved path. - /// - /// - /// The URL to check. - /// - /// true if the specified URL is reserved; otherwise, false. - /// - internal static bool IsReservedPathOrUrl(this IGlobalSettings globalSettings, string url) - { - if (_reservedUrlsCache == null) - { - lock (Locker) - { - if (_reservedUrlsCache == null) - { - // store references to strings to determine changes - _reservedPathsCache = globalSettings.ReservedPaths; - _reservedUrlsCache = globalSettings.ReservedUrls; - - // add URLs and paths to a new list - var newReservedList = new HashSet(); - foreach (var reservedUrlTrimmed in _reservedUrlsCache - .Split(new[] {","}, StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim().ToLowerInvariant()) - .Where(x => x.IsNullOrWhiteSpace() == false) - .Select(reservedUrl => IOHelper.ResolveUrl(reservedUrl).Trim().EnsureStartsWith("/")) - .Where(reservedUrlTrimmed => reservedUrlTrimmed.IsNullOrWhiteSpace() == false)) - { - newReservedList.Add(reservedUrlTrimmed); - } - - foreach (var reservedPathTrimmed in _reservedPathsCache - .Split(new[] {","}, StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim().ToLowerInvariant()) - .Where(x => x.IsNullOrWhiteSpace() == false) - .Select(reservedPath => IOHelper.ResolveUrl(reservedPath).Trim().EnsureStartsWith("/").EnsureEndsWith("/")) - .Where(reservedPathTrimmed => reservedPathTrimmed.IsNullOrWhiteSpace() == false)) - { - newReservedList.Add(reservedPathTrimmed); - } - - // use the new list from now on - _reservedList = newReservedList; - } - } - } - - //The url should be cleaned up before checking: - // * If it doesn't contain an '.' in the path then we assume it is a path based URL, if that is the case we should add an trailing '/' because all of our reservedPaths use a trailing '/' - // * We shouldn't be comparing the query at all - var pathPart = url.Split(new[] {'?'}, StringSplitOptions.RemoveEmptyEntries)[0].ToLowerInvariant(); - if (pathPart.Contains(".") == false) - { - pathPart = pathPart.EnsureEndsWith('/'); - } - - // return true if url starts with an element of the reserved list - return _reservedList.Any(x => pathPart.InvariantStartsWith(x)); - } - - /// - /// Determines whether the current request is reserved based on the route table and - /// whether the specified URL is reserved or is inside a reserved path. - /// - /// - /// - /// - /// The route collection to lookup the request in - /// - internal static bool IsReservedPathOrUrl(this IGlobalSettings globalSettings, string url, HttpContextBase httpContext, RouteCollection routes) - { - if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); - if (routes == null) throw new ArgumentNullException(nameof(routes)); - - //check if the current request matches a route, if so then it is reserved. - //TODO: This value should be cached! Else this is doing double routing in MVC every request! - var route = routes.GetRouteData(httpContext); - if (route != null) - return true; - - //continue with the standard ignore routine - return globalSettings.IsReservedPathOrUrl(url); - } - - } } diff --git a/src/Umbraco.Core/Configuration/Grid/IGridEditorConfig.cs b/src/Umbraco.Core/Configuration/Grid/IGridEditorConfig.cs index e447f7f493..9a11b0ef3e 100644 --- a/src/Umbraco.Core/Configuration/Grid/IGridEditorConfig.cs +++ b/src/Umbraco.Core/Configuration/Grid/IGridEditorConfig.cs @@ -6,6 +6,7 @@ namespace Umbraco.Core.Configuration.Grid public interface IGridEditorConfig { string Name { get; } + string NameTemplate { get; } string Alias { get; } string View { get; } string Render { get; } diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/ContentElement.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/ContentElement.cs index 5163dda1f6..77ad7df0dc 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/ContentElement.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/ContentElement.cs @@ -6,7 +6,7 @@ namespace Umbraco.Core.Configuration.UmbracoSettings { internal class ContentElement : UmbracoConfigurationElement, IContentSection { - private const string DefaultPreviewBadge = @"In Preview Mode - click to end"; + private const string DefaultPreviewBadge = @"
Preview modeClick to end
"; [ConfigurationProperty("imaging")] internal ContentImagingElement Imaging => (ContentImagingElement) this["imaging"]; diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IKeepAliveSection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IKeepAliveSection.cs new file mode 100644 index 0000000000..3a0ad258c5 --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IKeepAliveSection.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Core.Configuration.UmbracoSettings +{ + public interface IKeepAliveSection : IUmbracoConfigurationSection + { + bool DisableKeepAliveTask { get; } + string KeepAlivePingUrl { get; } + } +} diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IUmbracoSettingsSection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IUmbracoSettingsSection.cs index 81d27e7bae..acd9c92588 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/IUmbracoSettingsSection.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IUmbracoSettingsSection.cs @@ -15,5 +15,7 @@ namespace Umbraco.Core.Configuration.UmbracoSettings ILoggingSection Logging { get; } IWebRoutingSection WebRouting { get; } + + IKeepAliveSection KeepAlive { get; } } } diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/KeepAliveElement.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/KeepAliveElement.cs new file mode 100644 index 0000000000..89ba9be54d --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/KeepAliveElement.cs @@ -0,0 +1,13 @@ +using System.Configuration; + +namespace Umbraco.Core.Configuration.UmbracoSettings +{ + internal class KeepAliveElement : ConfigurationElement, IKeepAliveSection + { + [ConfigurationProperty("disableKeepAliveTask", DefaultValue = "false")] + public bool DisableKeepAliveTask => (bool)base["disableKeepAliveTask"]; + + [ConfigurationProperty("keepAlivePingUrl", DefaultValue = "{umbracoApplicationUrl}/api/keepalive/ping")] + public string KeepAlivePingUrl => (string)base["keepAlivePingUrl"]; + } +} diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/UmbracoSettingsSection.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/UmbracoSettingsSection.cs index 7cf8096345..17430c81eb 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/UmbracoSettingsSection.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/UmbracoSettingsSection.cs @@ -19,10 +19,12 @@ namespace Umbraco.Core.Configuration.UmbracoSettings [ConfigurationProperty("logging")] internal LoggingElement Logging => (LoggingElement)this["logging"]; - [ConfigurationProperty("web.routing")] internal WebRoutingElement WebRouting => (WebRoutingElement)this["web.routing"]; + [ConfigurationProperty("keepAlive")] + internal KeepAliveElement KeepAlive => (KeepAliveElement)this["keepAlive"]; + IContentSection IUmbracoSettingsSection.Content => Content; ISecuritySection IUmbracoSettingsSection.Security => Security; @@ -34,5 +36,7 @@ namespace Umbraco.Core.Configuration.UmbracoSettings ILoggingSection IUmbracoSettingsSection.Logging => Logging; IWebRoutingSection IUmbracoSettingsSection.WebRouting => WebRouting; + + IKeepAliveSection IUmbracoSettingsSection.KeepAlive => KeepAlive; } } diff --git a/src/Umbraco.Core/Constants-AppSettings.cs b/src/Umbraco.Core/Constants-AppSettings.cs index 509be46b61..704617d90c 100644 --- a/src/Umbraco.Core/Constants-AppSettings.cs +++ b/src/Umbraco.Core/Constants-AppSettings.cs @@ -9,6 +9,8 @@ namespace Umbraco.Core ///
public static class AppSettings { + public const string MainDomLock = "Umbraco.Core.MainDom.Lock"; + // TODO: Kill me - still used in Umbraco.Core.IO.SystemFiles:27 [Obsolete("We need to kill this appsetting as we do not use XML content cache umbraco.config anymore due to NuCache")] public const string ContentXML = "Umbraco.Core.ContentXML"; //umbracoContentXML @@ -41,6 +43,21 @@ namespace Umbraco.Core ///
public const string Path = "Umbraco.Core.Path"; + /// + /// Gets the path to the css directory (/css by default). + /// + public const string CssPath = "umbracoCssPath"; + + /// + /// Gets the path to the scripts directory (/scripts by default). + /// + public const string ScriptsPath = "umbracoScriptsPath"; + + /// + /// Gets the path to media directory (/media by default). + /// + public const string MediaPath = "umbracoMediaPath"; + /// /// The reserved urls from web.config. /// diff --git a/src/Umbraco.Core/Constants-Conventions.cs b/src/Umbraco.Core/Constants-Conventions.cs index 6c9407667a..c1d7103a1c 100644 --- a/src/Umbraco.Core/Constants-Conventions.cs +++ b/src/Umbraco.Core/Constants-Conventions.cs @@ -221,7 +221,8 @@ namespace Umbraco.Core FailedPasswordAttempts, new PropertyType(PropertyEditors.Aliases.Label, ValueStorageType.Integer, true, FailedPasswordAttempts) { - Name = FailedPasswordAttemptsLabel + Name = FailedPasswordAttemptsLabel, + DataTypeId = Constants.DataTypes.LabelInt } }, { @@ -242,35 +243,40 @@ namespace Umbraco.Core LastLockoutDate, new PropertyType(PropertyEditors.Aliases.Label, ValueStorageType.Date, true, LastLockoutDate) { - Name = LastLockoutDateLabel + Name = LastLockoutDateLabel, + DataTypeId = Constants.DataTypes.LabelDateTime } }, { LastLoginDate, new PropertyType(PropertyEditors.Aliases.Label, ValueStorageType.Date, true, LastLoginDate) { - Name = LastLoginDateLabel + Name = LastLoginDateLabel, + DataTypeId = Constants.DataTypes.LabelDateTime } }, { LastPasswordChangeDate, new PropertyType(PropertyEditors.Aliases.Label, ValueStorageType.Date, true, LastPasswordChangeDate) { - Name = LastPasswordChangeDateLabel + Name = LastPasswordChangeDateLabel, + DataTypeId = Constants.DataTypes.LabelDateTime } }, { PasswordAnswer, new PropertyType(PropertyEditors.Aliases.Label, ValueStorageType.Nvarchar, true, PasswordAnswer) { - Name = PasswordAnswerLabel + Name = PasswordAnswerLabel, + DataTypeId = Constants.DataTypes.LabelString } }, { PasswordQuestion, new PropertyType(PropertyEditors.Aliases.Label, ValueStorageType.Nvarchar, true, PasswordQuestion) { - Name = PasswordQuestionLabel + Name = PasswordQuestionLabel, + DataTypeId = Constants.DataTypes.LabelString } } }; @@ -309,34 +315,65 @@ namespace Umbraco.Core public static class RelationTypes { /// - /// ContentType name for default relation type "Relate Document On Copy". + /// Name for default relation type "Related Media". + /// + public const string RelatedMediaName = "Related Media"; + + /// + /// Alias for default relation type "Related Media" + /// + public const string RelatedMediaAlias = "umbMedia"; + + /// + /// Name for default relation type "Related Document". + /// + public const string RelatedDocumentName = "Related Document"; + + /// + /// Alias for default relation type "Related Document" + /// + public const string RelatedDocumentAlias = "umbDocument"; + + /// + /// Name for default relation type "Relate Document On Copy". /// public const string RelateDocumentOnCopyName = "Relate Document On Copy"; /// - /// ContentType alias for default relation type "Relate Document On Copy". + /// Alias for default relation type "Relate Document On Copy". /// public const string RelateDocumentOnCopyAlias = "relateDocumentOnCopy"; /// - /// ContentType name for default relation type "Relate Parent Document On Delete". + /// Name for default relation type "Relate Parent Document On Delete". /// public const string RelateParentDocumentOnDeleteName = "Relate Parent Document On Delete"; /// - /// ContentType alias for default relation type "Relate Parent Document On Delete". + /// Alias for default relation type "Relate Parent Document On Delete". /// public const string RelateParentDocumentOnDeleteAlias = "relateParentDocumentOnDelete"; /// - /// ContentType name for default relation type "Relate Parent Media Folder On Delete". + /// Name for default relation type "Relate Parent Media Folder On Delete". /// public const string RelateParentMediaFolderOnDeleteName = "Relate Parent Media Folder On Delete"; /// - /// ContentType alias for default relation type "Relate Parent Media Folder On Delete". + /// Alias for default relation type "Relate Parent Media Folder On Delete". /// public const string RelateParentMediaFolderOnDeleteAlias = "relateParentMediaFolderOnDelete"; + + /// + /// Returns the types of relations that are automatically tracked + /// + /// + /// Developers should not manually use these relation types since they will all be cleared whenever an entity + /// (content, media or member) is saved since they are auto-populated based on property values. + /// + public static string[] AutomaticRelationTypes = new[] { RelatedMediaAlias, RelatedDocumentAlias }; + + //TODO: return a list of built in types so we can use that to prevent deletion in the uI } } } diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index b48286f197..eb2b3525a7 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -85,17 +85,17 @@ namespace Umbraco.Core /// ListView. ///
public const string ListView = "Umbraco.ListView"; - - /// - /// Macro Container. - /// - public const string MacroContainer = "Umbraco.MacroContainer"; - + /// /// Media Picker. /// public const string MediaPicker = "Umbraco.MediaPicker"; + /// + /// Multiple Media Picker. + /// + public const string MultipleMediaPicker = "Umbraco.MultipleMediaPicker"; + /// /// Member Picker. /// @@ -203,6 +203,24 @@ namespace Umbraco.Core /// Must be a valid value. public const string DataValueType = "umbracoDataValueType"; } + + /// + /// Defines Umbraco's built-in property editor groups. + /// + public static class Groups + { + public const string Common = "Common"; + + public const string Lists = "Lists"; + + public const string Media = "Media"; + + public const string People = "People"; + + public const string Pickers = "Pickers"; + + public const string RichContent = "Rich Content"; + } } } } diff --git a/src/Umbraco.Core/UdiEntityType.cs b/src/Umbraco.Core/Contants-UdiEntityType.cs similarity index 100% rename from src/Umbraco.Core/UdiEntityType.cs rename to src/Umbraco.Core/Contants-UdiEntityType.cs diff --git a/src/Umbraco.Core/ContentExtensions.cs b/src/Umbraco.Core/ContentExtensions.cs index a7d40b0b7d..3edad0c963 100644 --- a/src/Umbraco.Core/ContentExtensions.cs +++ b/src/Umbraco.Core/ContentExtensions.cs @@ -13,6 +13,7 @@ using Umbraco.Core.IO; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; using Umbraco.Core.Models.Membership; +using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; using Umbraco.Core.Services.Implement; @@ -23,6 +24,8 @@ namespace Umbraco.Core // this ain't pretty private static IMediaFileSystem _mediaFileSystem; private static IMediaFileSystem MediaFileSystem => _mediaFileSystem ?? (_mediaFileSystem = Current.MediaFileSystem); + private static readonly PropertyEditorCollection _propertyEditors; + private static PropertyEditorCollection PropertyEditors = _propertyEditors ?? (_propertyEditors = Current.PropertyEditors); #region IContent @@ -162,14 +165,12 @@ namespace Umbraco.Core // Fixes https://github.com/umbraco/Umbraco-CMS/issues/3937 - Assigning a new file to an // existing IMedia with extension SetValue causes exception 'Illegal characters in path' string oldpath = null; - if (property.GetValue(culture, segment) is string svalue) + var value = property.GetValue(culture, segment); + + if (PropertyEditors.TryGet(propertyTypeAlias, out var editor) + && editor is IDataEditorWithMediaPath dataEditor) { - if (svalue.DetectIsJson()) - { - // the property value is a JSON serialized image crop data set - grab the "src" property as the file source - var jObject = JsonConvert.DeserializeObject(svalue); - svalue = jObject != null ? jObject.GetValueAsString("src") : svalue; - } + var svalue = dataEditor.GetMediaPath(value); oldpath = MediaFileSystem.GetRelativePath(svalue); } diff --git a/src/Umbraco.Core/ContentVariationExtensions.cs b/src/Umbraco.Core/ContentVariationExtensions.cs index 9fdc5f0b90..29442b78e6 100644 --- a/src/Umbraco.Core/ContentVariationExtensions.cs +++ b/src/Umbraco.Core/ContentVariationExtensions.cs @@ -12,121 +12,260 @@ namespace Umbraco.Core /// /// Determines whether the content type is invariant. /// + /// The content type. + /// + /// A value indicating whether the content type is invariant. + /// public static bool VariesByNothing(this ISimpleContentType contentType) => contentType.Variations.VariesByNothing(); - /// - /// Determines whether the content type varies by culture. - /// - public static bool VariesByCulture(this ISimpleContentType contentType) => contentType.Variations.VariesByCulture(); - /// /// Determines whether the content type is invariant. /// + /// The content type. + /// + /// A value indicating whether the content type is invariant. + /// public static bool VariesByNothing(this IContentTypeBase contentType) => contentType.Variations.VariesByNothing(); - /// - /// Determines whether the content type varies by culture. - /// - /// And then it could also vary by segment. - public static bool VariesByCulture(this IContentTypeBase contentType) => contentType.Variations.VariesByCulture(); - - /// - /// Determines whether the content type varies by segment. - /// - /// And then it could also vary by culture. - public static bool VariesBySegment(this IContentTypeBase contentType) => contentType.Variations.VariesBySegment(); - - /// - /// Determines whether the content type varies by culture and segment. - /// - public static bool VariesByCultureAndSegment(this IContentTypeBase contentType) => contentType.Variations.VariesByCultureAndSegment(); - - /// - /// Determines whether the property type is invariant. - /// - public static bool VariesByNothing(this PropertyType propertyType) => propertyType.Variations.VariesByNothing(); - - /// - /// Determines whether the property type varies by culture. - /// - /// And then it could also vary by segment. - public static bool VariesByCulture(this PropertyType propertyType) => propertyType.Variations.VariesByCulture(); - - /// - /// Determines whether the property type varies by segment. - /// - /// And then it could also vary by culture. - public static bool VariesBySegment(this PropertyType propertyType) => propertyType.Variations.VariesBySegment(); - - /// - /// Determines whether the property type varies by culture and segment. - /// - public static bool VariesByCultureAndSegment(this PropertyType propertyType) => propertyType.Variations.VariesByCultureAndSegment(); - /// /// Determines whether the content type is invariant. /// + /// The content type. + /// + /// A value indicating whether the content type is invariant. + /// public static bool VariesByNothing(this IPublishedContentType contentType) => contentType.Variations.VariesByNothing(); /// - /// Determines whether the content type varies by culture. + /// Determines whether the property type is invariant. /// - /// And then it could also vary by segment. - public static bool VariesByCulture(this IPublishedContentType contentType) => contentType.Variations.VariesByCulture(); - - /// - /// Determines whether the content type varies by segment. - /// - /// And then it could also vary by culture. - public static bool VariesBySegment(this IPublishedContentType contentType) => contentType.Variations.VariesBySegment(); - - /// - /// Determines whether the content type varies by culture and segment. - /// - public static bool VariesByCultureAndSegment(this IPublishedContentType contentType) => contentType.Variations.VariesByCultureAndSegment(); + /// The property type. + /// + /// A value indicating whether the property type is invariant. + /// + public static bool VariesByNothing(this PropertyType propertyType) => propertyType.Variations.VariesByNothing(); /// /// Determines whether the property type is invariant. /// + /// The property type. + /// + /// A value indicating whether the property type is invariant. + /// public static bool VariesByNothing(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByNothing(); - /// - /// Determines whether the property type varies by culture. - /// - public static bool VariesByCulture(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByCulture(); - - /// - /// Determines whether the property type varies by segment. - /// - public static bool VariesBySegment(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesBySegment(); - - /// - /// Determines whether the property type varies by culture and segment. - /// - public static bool VariesByCultureAndSegment(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByCultureAndSegment(); - /// /// Determines whether a variation is invariant. /// + /// The variation. + /// + /// A value indicating whether the variation is invariant. + /// public static bool VariesByNothing(this ContentVariation variation) => variation == ContentVariation.Nothing; + /// + /// Determines whether the content type varies by culture. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture. + /// + public static bool VariesByCulture(this ISimpleContentType contentType) => contentType.Variations.VariesByCulture(); + + /// + /// Determines whether the content type varies by culture. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture. + /// + public static bool VariesByCulture(this IContentTypeBase contentType) => contentType.Variations.VariesByCulture(); + + /// + /// Determines whether the content type varies by culture. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture. + /// + public static bool VariesByCulture(this IPublishedContentType contentType) => contentType.Variations.VariesByCulture(); + + /// + /// Determines whether the property type varies by culture. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by culture. + /// + public static bool VariesByCulture(this PropertyType propertyType) => propertyType.Variations.VariesByCulture(); + + /// + /// Determines whether the property type varies by culture. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by culture. + /// + public static bool VariesByCulture(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByCulture(); + /// /// Determines whether a variation varies by culture. /// - /// And then it could also vary by segment. + /// The variation. + /// + /// A value indicating whether the variation varies by culture. + /// public static bool VariesByCulture(this ContentVariation variation) => (variation & ContentVariation.Culture) > 0; + /// + /// Determines whether the content type varies by segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by segment. + /// + public static bool VariesBySegment(this ISimpleContentType contentType) => contentType.Variations.VariesBySegment(); + + /// + /// Determines whether the content type varies by segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by segment. + /// + public static bool VariesBySegment(this IContentTypeBase contentType) => contentType.Variations.VariesBySegment(); + + /// + /// Determines whether the content type varies by segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by segment. + /// + public static bool VariesBySegment(this IPublishedContentType contentType) => contentType.Variations.VariesBySegment(); + + /// + /// Determines whether the property type varies by segment. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by segment. + /// + public static bool VariesBySegment(this PropertyType propertyType) => propertyType.Variations.VariesBySegment(); + + /// + /// Determines whether the property type varies by segment. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by segment. + /// + public static bool VariesBySegment(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesBySegment(); + /// /// Determines whether a variation varies by segment. /// - /// And then it could also vary by culture. + /// The variation. + /// + /// A value indicating whether the variation varies by segment. + /// public static bool VariesBySegment(this ContentVariation variation) => (variation & ContentVariation.Segment) > 0; + /// + /// Determines whether the content type varies by culture and segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this ISimpleContentType contentType) => contentType.Variations.VariesByCultureAndSegment(); + + /// + /// Determines whether the content type varies by culture and segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this IContentTypeBase contentType) => contentType.Variations.VariesByCultureAndSegment(); + + /// + /// Determines whether the content type varies by culture and segment. + /// + /// The content type. + /// + /// A value indicating whether the content type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this IPublishedContentType contentType) => contentType.Variations.VariesByCultureAndSegment(); + + /// + /// Determines whether the property type varies by culture and segment. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this PropertyType propertyType) => propertyType.Variations.VariesByCultureAndSegment(); + + /// + /// Determines whether the property type varies by culture and segment. + /// + /// The property type. + /// + /// A value indicating whether the property type varies by culture and segment. + /// + public static bool VariesByCultureAndSegment(this IPublishedPropertyType propertyType) => propertyType.Variations.VariesByCultureAndSegment(); + /// /// Determines whether a variation varies by culture and segment. /// + /// The variation. + /// + /// A value indicating whether the variation varies by culture and segment. + /// public static bool VariesByCultureAndSegment(this ContentVariation variation) => (variation & ContentVariation.CultureAndSegment) == ContentVariation.CultureAndSegment; + /// + /// Sets or removes the content type variation depending on the specified value. + /// + /// The content type. + /// The variation to set or remove. + /// If set to true sets the variation; otherwise, removes the variation. + /// + /// This method does not support setting the variation to nothing. + /// + public static void SetVariesBy(this IContentTypeBase contentType, ContentVariation variation, bool value = true) => contentType.Variations = contentType.Variations.SetFlag(variation, value); + + /// + /// Sets or removes the property type variation depending on the specified value. + /// + /// The property type. + /// The variation to set or remove. + /// If set to true sets the variation; otherwise, removes the variation. + /// + /// This method does not support setting the variation to nothing. + /// + public static void SetVariesBy(this PropertyType propertyType, ContentVariation variation, bool value = true) => propertyType.Variations = propertyType.Variations.SetFlag(variation, value); + + /// + /// Returns the variations with the variation set or removed depending on the specified value. + /// + /// The existing variations. + /// The variation to set or remove. + /// If set to true sets the variation; otherwise, removes the variation. + /// + /// The variations with the variation set or removed. + /// + /// + /// This method does not support setting the variation to nothing. + /// + public static ContentVariation SetFlag(this ContentVariation variations, ContentVariation variation, bool value = true) + { + return value + ? variations | variation // Set flag using bitwise logical OR + : variations & ~variation; // Remove flag using bitwise logical AND with bitwise complement (reversing the bit) + } + /// /// Validates that a combination of culture and segment is valid for the variation. /// @@ -135,54 +274,66 @@ namespace Umbraco.Core /// The segment. /// A value indicating whether to perform exact validation. /// A value indicating whether to support wildcards. - /// A value indicating whether to throw a when the combination is invalid. - /// True if the combination is valid; otherwise false. + /// A value indicating whether to throw a when the combination is invalid. + /// + /// true if the combination is valid; otherwise false. + /// + /// Occurs when the combination is invalid, and is true. /// /// When validation is exact, the combination must match the variation exactly. For instance, if the variation is Culture, then /// a culture is required. When validation is not strict, the combination must be equivalent, or more restrictive: if the variation is /// Culture, an invariant combination is ok. /// Basically, exact is for one content type, or one property type, and !exact is for "all property types" of one content type. - /// Both and can be "*" to indicate "all of them". + /// Both and can be "*" to indicate "all of them". /// - /// Occurs when the combination is invalid, and is true. public static bool ValidateVariation(this ContentVariation variation, string culture, string segment, bool exact, bool wildcards, bool throwIfInvalid) { culture = culture.NullOrWhiteSpaceAsNull(); segment = segment.NullOrWhiteSpaceAsNull(); - bool Validate(bool variesBy, string value) - { - if (variesBy) - { - // varies by - // in exact mode, the value cannot be null (but it can be a wildcard) - // in !wildcards mode, the value cannot be a wildcard (but it can be null) - if ((exact && value == null) || (!wildcards && value == "*")) - return false; - } - else - { - // does not vary by value - // the value cannot have a value - // unless wildcards and it's "*" - if (value != null && (!wildcards || value != "*")) - return false; - } - - return true; - } - - if (!Validate(variation.VariesByCulture(), culture)) + // if wildcards are disabled, do not allow "*" + if (!wildcards && (culture == "*" || segment == "*")) { if (throwIfInvalid) - throw new NotSupportedException($"Culture value \"{culture ?? ""}\" is invalid."); + throw new NotSupportedException($"Variation wildcards are not supported."); return false; } - if (!Validate(variation.VariesBySegment(), segment)) + if (variation.VariesByCulture()) + { + // varies by culture + // in exact mode, the culture cannot be null + if (exact && culture == null) + { + if (throwIfInvalid) + throw new NotSupportedException($"Culture may not be null because culture variation is enabled."); + + return false; + } + } + else + { + // does not vary by culture + // the culture cannot have a value + // unless wildcards and it's "*" + if (culture != null && !(wildcards && culture == "*")) + { + if (throwIfInvalid) + throw new NotSupportedException($"Culture \"{culture}\" is invalid because culture variation is disabled."); + + return false; + } + } + + // if it does not vary by segment + // the segment cannot have a value + // segment may always be null, even when the ContentVariation.Segment flag is set for this variation, + // therefore the exact parameter is not used in segment validation. + if (!variation.VariesBySegment() && segment != null && !(wildcards && segment == "*")) { if (throwIfInvalid) - throw new NotSupportedException($"Segment value \"{segment ?? ""}\" is invalid."); + throw new NotSupportedException($"Segment \"{segment}\" is invalid because segment variation is disabled."); + return false; } diff --git a/src/Umbraco.Core/DateTimeExtensions.cs b/src/Umbraco.Core/DateTimeExtensions.cs index 66b788f9c8..f665aaa8ed 100644 --- a/src/Umbraco.Core/DateTimeExtensions.cs +++ b/src/Umbraco.Core/DateTimeExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; @@ -15,7 +16,7 @@ namespace Umbraco.Core /// public static string ToIsoString(this DateTime dt) { - return dt.ToString("yyyy-MM-dd HH:mm:ss"); + return dt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); } public static DateTime TruncateTo(this DateTime dt, DateTruncate truncateTo) diff --git a/src/Umbraco.Core/EnumExtensions.cs b/src/Umbraco.Core/EnumExtensions.cs index 1443a27876..9097432f64 100644 --- a/src/Umbraco.Core/EnumExtensions.cs +++ b/src/Umbraco.Core/EnumExtensions.cs @@ -3,43 +3,42 @@ namespace Umbraco.Core { /// - /// Provides extension methods to enums. + /// Provides extension methods to . /// public static class EnumExtensions { - // note: - // - no need to HasFlagExact, that's basically an == test - // - HasFlagAll cannot be named HasFlag because ext. methods never take priority over instance methods - /// - /// Determines whether a flag enum has all the specified values. + /// Determines whether all the flags/bits are set within the enum value. /// - /// - /// True when all bits set in are set in , though other bits may be set too. - /// This is the behavior of the original method. - /// - public static bool HasFlagAll(this T use, T uses) + /// The enum type. + /// The enum value. + /// The flags. + /// + /// true if all the flags/bits are set within the enum value; otherwise, false. + /// + [Obsolete("Use Enum.HasFlag() or bitwise operations (if performance is important) instead.")] + public static bool HasFlagAll(this T value, T flags) where T : Enum { - var num = Convert.ToUInt64(use); - var nums = Convert.ToUInt64(uses); - - return (num & nums) == nums; + return value.HasFlag(flags); } /// - /// Determines whether a flag enum has any of the specified values. + /// Determines whether any of the flags/bits are set within the enum value. /// - /// - /// True when at least one of the bits set in is set in . - /// - public static bool HasFlagAny(this T use, T uses) + /// The enum type. + /// The value. + /// The flags. + /// + /// true if any of the flags/bits are set within the enum value; otherwise, false. + /// + public static bool HasFlagAny(this T value, T flags) where T : Enum { - var num = Convert.ToUInt64(use); - var nums = Convert.ToUInt64(uses); + var v = Convert.ToUInt64(value); + var f = Convert.ToUInt64(flags); - return (num & nums) > 0; + return (v & f) > 0; } } } diff --git a/src/Umbraco.Core/Exceptions/ArgumentNullOrEmptyException.cs b/src/Umbraco.Core/Exceptions/ArgumentNullOrEmptyException.cs index 90cc20c404..037d35d0ee 100644 --- a/src/Umbraco.Core/Exceptions/ArgumentNullOrEmptyException.cs +++ b/src/Umbraco.Core/Exceptions/ArgumentNullOrEmptyException.cs @@ -1,16 +1,24 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Core.Exceptions { /// - /// The exception that is thrown when a null reference, or an empty argument, - /// is passed to a method that does not accept it as a valid argument. + /// The exception that is thrown when a null reference, or an empty argument, is passed to a method that does not accept it as a valid argument. /// + /// + [Obsolete("Throw an ArgumentNullException when the parameter is null or an ArgumentException when its empty instead.")] + [Serializable] public class ArgumentNullOrEmptyException : ArgumentNullException { /// - /// Initializes a new instance of the class - /// with the name of the parameter that caused this exception. + /// Initializes a new instance of the class. + /// + public ArgumentNullOrEmptyException() + { } + + /// + /// Initializes a new instance of the class with the name of the parameter that caused this exception. /// /// The named of the parameter that caused the exception. public ArgumentNullOrEmptyException(string paramName) @@ -18,13 +26,30 @@ namespace Umbraco.Core.Exceptions { } /// - /// Initializes a new instance of the class - /// with a specified error message and the name of the parameter that caused this exception. + /// Initializes a new instance of the class with a specified error message and the name of the parameter that caused this exception. /// /// The named of the parameter that caused the exception. /// A message that describes the error. public ArgumentNullOrEmptyException(string paramName, string message) : base(paramName, message) { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for this exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public ArgumentNullOrEmptyException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The object that holds the serialized object data. + /// An object that describes the source or destination of the serialized data. + protected ArgumentNullOrEmptyException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } } diff --git a/src/Umbraco.Core/Exceptions/AuthorizationException.cs b/src/Umbraco.Core/Exceptions/AuthorizationException.cs index 955fec270b..b87a8da8b8 100644 --- a/src/Umbraco.Core/Exceptions/AuthorizationException.cs +++ b/src/Umbraco.Core/Exceptions/AuthorizationException.cs @@ -1,14 +1,45 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Core.Exceptions { + /// + /// The exception that is thrown when authorization failed. + /// + /// + [Serializable] public class AuthorizationException : Exception { + /// + /// Initializes a new instance of the class. + /// public AuthorizationException() { } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. public AuthorizationException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public AuthorizationException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected AuthorizationException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } } diff --git a/src/Umbraco.Core/Exceptions/BootFailedException.cs b/src/Umbraco.Core/Exceptions/BootFailedException.cs index c3262d26c6..e8ffe1d2e9 100644 --- a/src/Umbraco.Core/Exceptions/BootFailedException.cs +++ b/src/Umbraco.Core/Exceptions/BootFailedException.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.Serialization; using System.Text; namespace Umbraco.Core.Exceptions @@ -6,6 +7,8 @@ namespace Umbraco.Core.Exceptions /// /// An exception that is thrown if the Umbraco application cannot boot. /// + /// + [Serializable] public class BootFailedException : Exception { /// @@ -14,27 +17,47 @@ namespace Umbraco.Core.Exceptions public const string DefaultMessage = "Boot failed: Umbraco cannot run. See Umbraco's log file for more details."; /// - /// Initializes a new instance of the class with a specified error message. + /// Initializes a new instance of the class. /// - /// The message that describes the error. + public BootFailedException() + { } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. public BootFailedException(string message) : base(message) { } /// - /// Initializes a new instance of the class with a specified error message + /// Initializes a new instance of the class with a specified error message /// and a reference to the inner exception which is the cause of this exception. /// - /// The message that describes the error. - /// The inner exception, or null. - public BootFailedException(string message, Exception inner) - : base(message, inner) + /// The message that describes the error. + /// The inner exception, or null. + public BootFailedException(string message, Exception innerException) + : base(message, innerException) { } /// - /// Rethrows a captured . + /// Initializes a new instance of the class. /// - /// The exception can be null, in which case a default message is used. + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected BootFailedException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + + /// + /// Rethrows a captured . + /// + /// The boot failed exception. + /// + /// + /// + /// The exception can be null, in which case a default message is used. + /// public static void Rethrow(BootFailedException bootFailedException) { if (bootFailedException == null) diff --git a/src/Umbraco.Core/Exceptions/ConnectionException.cs b/src/Umbraco.Core/Exceptions/ConnectionException.cs deleted file mode 100644 index 64fdbeee52..0000000000 --- a/src/Umbraco.Core/Exceptions/ConnectionException.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace Umbraco.Core.Exceptions -{ - internal class ConnectionException : Exception - { - public ConnectionException(string message, Exception innerException) : base(message, innerException) - { - - } - } -} diff --git a/src/Umbraco.Core/Exceptions/DataOperationException.cs b/src/Umbraco.Core/Exceptions/DataOperationException.cs index 14fefcf9d3..c004e391fe 100644 --- a/src/Umbraco.Core/Exceptions/DataOperationException.cs +++ b/src/Umbraco.Core/Exceptions/DataOperationException.cs @@ -1,21 +1,98 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Core.Exceptions { + /// + /// + /// + /// + /// + [Serializable] internal class DataOperationException : Exception + where T : Enum { + /// + /// Gets the operation. + /// + /// + /// The operation. + /// + /// + /// This object should be serializable to prevent a to be thrown. + /// public T Operation { get; private set; } + /// + /// Initializes a new instance of the class. + /// + public DataOperationException() + { } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public DataOperationException(string message) + : base(message) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public DataOperationException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The operation. + public DataOperationException(T operation) + : this(operation, "Data operation exception: " + operation) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The operation. + /// The message. public DataOperationException(T operation, string message) : base(message) { Operation = operation; } - public DataOperationException(T operation) - : base("Data operation exception: " + operation) + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + /// info + protected DataOperationException(SerializationInfo info, StreamingContext context) + : base(info, context) { - Operation = operation; + Operation = (T)Enum.Parse(typeof(T), info.GetString(nameof(Operation))); + } + + /// + /// When overridden in a derived class, sets the with information about the exception. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + /// info + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + + info.AddValue(nameof(Operation), Enum.GetName(typeof(T), Operation)); + + base.GetObjectData(info, context); } } } diff --git a/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs b/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs index 9d154c6a6f..684e23b020 100644 --- a/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs +++ b/src/Umbraco.Core/Exceptions/InvalidCompositionException.cs @@ -1,44 +1,126 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Core.Exceptions { + /// + /// The exception that is thrown when a composition is invalid. + /// + /// + [Serializable] public class InvalidCompositionException : Exception { - public InvalidCompositionException(string contentTypeAlias, string addedCompositionAlias, string[] propertyTypeAliass) - { - ContentTypeAlias = contentTypeAlias; - AddedCompositionAlias = addedCompositionAlias; - PropertyTypeAliases = propertyTypeAliass; - } + /// + /// Gets the content type alias. + /// + /// + /// The content type alias. + /// + public string ContentTypeAlias { get; } - public InvalidCompositionException(string contentTypeAlias, string[] propertyTypeAliass) - { - ContentTypeAlias = contentTypeAlias; - PropertyTypeAliases = propertyTypeAliass; - } + /// + /// Gets the added composition alias. + /// + /// + /// The added composition alias. + /// + public string AddedCompositionAlias { get; } - public string ContentTypeAlias { get; private set; } + /// + /// Gets the property type aliases. + /// + /// + /// The property type aliases. + /// + public string[] PropertyTypeAliases { get; } - public string AddedCompositionAlias { get; private set; } + /// + /// Initializes a new instance of the class. + /// + public InvalidCompositionException() + { } - public string[] PropertyTypeAliases { get; private set; } + /// + /// Initializes a new instance of the class. + /// + /// The content type alias. + /// The property type aliases. + public InvalidCompositionException(string contentTypeAlias, string[] propertyTypeAliases) + : this(contentTypeAlias, null, propertyTypeAliases) + { } - public override string Message - { - get - { - return AddedCompositionAlias.IsNullOrWhiteSpace() + /// + /// Initializes a new instance of the class. + /// + /// The content type alias. + /// The added composition alias. + /// The property type aliases. + public InvalidCompositionException(string contentTypeAlias, string addedCompositionAlias, string[] propertyTypeAliases) + : this(addedCompositionAlias.IsNullOrWhiteSpace() ? string.Format( "ContentType with alias '{0}' has an invalid composition " + "and there was a conflict on the following PropertyTypes: '{1}'. " + "PropertyTypes must have a unique alias across all Compositions in order to compose a valid ContentType Composition.", - ContentTypeAlias, string.Join(", ", PropertyTypeAliases)) + contentTypeAlias, string.Join(", ", propertyTypeAliases)) : string.Format( "ContentType with alias '{0}' was added as a Composition to ContentType with alias '{1}', " + "but there was a conflict on the following PropertyTypes: '{2}'. " + "PropertyTypes must have a unique alias across all Compositions in order to compose a valid ContentType Composition.", - AddedCompositionAlias, ContentTypeAlias, string.Join(", ", PropertyTypeAliases)); + addedCompositionAlias, contentTypeAlias, string.Join(", ", propertyTypeAliases))) + { + ContentTypeAlias = contentTypeAlias; + AddedCompositionAlias = addedCompositionAlias; + PropertyTypeAliases = propertyTypeAliases; + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public InvalidCompositionException(string message) + : base(message) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public InvalidCompositionException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected InvalidCompositionException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + ContentTypeAlias = info.GetString(nameof(ContentTypeAlias)); + AddedCompositionAlias = info.GetString(nameof(AddedCompositionAlias)); + PropertyTypeAliases = (string[])info.GetValue(nameof(PropertyTypeAliases), typeof(string[])); + } + + /// + /// When overridden in a derived class, sets the with information about the exception. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + /// info + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); } + + info.AddValue(nameof(ContentTypeAlias), ContentTypeAlias); + info.AddValue(nameof(AddedCompositionAlias), AddedCompositionAlias); + info.AddValue(nameof(PropertyTypeAliases), PropertyTypeAliases); + + base.GetObjectData(info, context); } } } diff --git a/src/Umbraco.Core/Exceptions/PanicException.cs b/src/Umbraco.Core/Exceptions/PanicException.cs new file mode 100644 index 0000000000..75edf7fd73 --- /dev/null +++ b/src/Umbraco.Core/Exceptions/PanicException.cs @@ -0,0 +1,45 @@ +using System; +using System.Runtime.Serialization; + +namespace Umbraco.Core.Exceptions +{ + /// + /// Represents an internal exception that in theory should never been thrown, it is only thrown in circumstances that should never happen. + /// + /// + [Serializable] + public class PanicException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public PanicException() + { } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public PanicException(string message) + : base(message) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public PanicException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected PanicException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } +} diff --git a/src/Umbraco.Core/Exceptions/WontImplementException.cs b/src/Umbraco.Core/Exceptions/WontImplementException.cs index 7774bf53de..e354bc4c3d 100644 --- a/src/Umbraco.Core/Exceptions/WontImplementException.cs +++ b/src/Umbraco.Core/Exceptions/WontImplementException.cs @@ -1,27 +1,52 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Core.Exceptions { /// /// The exception that is thrown when a requested method or operation is not, and will not be, implemented. /// - /// The is to be used when some code is not implemented, + /// + /// The is to be used when some code is not implemented, /// but should eventually be implemented (i.e. work in progress) and is reported by tools such as ReSharper. /// This exception is to be used when some code is not implemented, and is not meant to be, for whatever - /// reason. + /// reason. + /// + /// + [Serializable] + [Obsolete("If a method or operation is not, and will not be, implemented, it is invalid or not supported, so we should throw either an InvalidOperationException or NotSupportedException instead.")] public class WontImplementException : NotImplementedException { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// public WontImplementException() { } /// - /// Initializes a new instance of the class with a specified reason message. + /// Initializes a new instance of the class with a specified reason message. /// + /// The error message that explains the reason for the exception. public WontImplementException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. If the parameter is not , the current exception is raised in a block that handles the inner exception. + public WontImplementException(string message, Exception inner) + : base(message, inner) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected WontImplementException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } } diff --git a/src/Umbraco.Core/IO/FileSecurityException.cs b/src/Umbraco.Core/IO/FileSecurityException.cs index 7b4f7d2625..8ce9ab34a5 100644 --- a/src/Umbraco.Core/IO/FileSecurityException.cs +++ b/src/Umbraco.Core/IO/FileSecurityException.cs @@ -1,20 +1,46 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Runtime.Serialization; namespace Umbraco.Core.IO { + /// + /// The exception that is thrown when the caller does not have the required permission to access a file. + /// + /// + [Obsolete("Throw an UnauthorizedAccessException instead.")] + [Serializable] public class FileSecurityException : Exception { + /// + /// Initializes a new instance of the class. + /// public FileSecurityException() - { + { } - } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public FileSecurityException(string message) + : base(message) + { } - public FileSecurityException(string message) : base(message) - { + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public FileSecurityException(string message, Exception innerException) + : base(message, innerException) + { } - } + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected FileSecurityException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } } diff --git a/src/Umbraco.Core/IO/MediaFileSystem.cs b/src/Umbraco.Core/IO/MediaFileSystem.cs index 2ce1230bcc..05c02171ba 100644 --- a/src/Umbraco.Core/IO/MediaFileSystem.cs +++ b/src/Umbraco.Core/IO/MediaFileSystem.cs @@ -1,15 +1,10 @@ using System; using System.Collections.Generic; -using System.Configuration; using System.IO; using System.Linq; using System.Threading.Tasks; -using Umbraco.Core.Composing; -using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; -using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; -using Umbraco.Core.Media; using Umbraco.Core.Models; namespace Umbraco.Core.IO @@ -92,7 +87,8 @@ namespace Umbraco.Core.IO { if (content == null) throw new ArgumentNullException(nameof(content)); if (propertyType == null) throw new ArgumentNullException(nameof(propertyType)); - if (string.IsNullOrWhiteSpace(filename)) throw new ArgumentNullOrEmptyException(nameof(filename)); + if (filename == null) throw new ArgumentNullException(nameof(filename)); + if (string.IsNullOrWhiteSpace(filename)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(filename)); if (filestream == null) throw new ArgumentNullException(nameof(filestream)); // clear the old file, if any @@ -111,7 +107,8 @@ namespace Umbraco.Core.IO { if (content == null) throw new ArgumentNullException(nameof(content)); if (propertyType == null) throw new ArgumentNullException(nameof(propertyType)); - if (string.IsNullOrWhiteSpace(sourcepath)) throw new ArgumentNullOrEmptyException(nameof(sourcepath)); + if (sourcepath == null) throw new ArgumentNullException(nameof(sourcepath)); + if (string.IsNullOrWhiteSpace(sourcepath)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(sourcepath)); // ensure we have a file to copy if (FileExists(sourcepath) == false) return null; diff --git a/src/Umbraco.Core/IO/PhysicalFileSystem.cs b/src/Umbraco.Core/IO/PhysicalFileSystem.cs index e4edb2b86b..96aaf7ca27 100644 --- a/src/Umbraco.Core/IO/PhysicalFileSystem.cs +++ b/src/Umbraco.Core/IO/PhysicalFileSystem.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using Umbraco.Core.Composing; -using Umbraco.Core.Exceptions; using System.Threading; using Umbraco.Core.Logging; @@ -39,9 +38,11 @@ namespace Umbraco.Core.IO public PhysicalFileSystem(string rootPath, string rootUrl) { - if (string.IsNullOrEmpty(rootPath)) throw new ArgumentNullOrEmptyException(nameof(rootPath)); - if (string.IsNullOrEmpty(rootUrl)) throw new ArgumentNullOrEmptyException(nameof(rootUrl)); - if (rootPath.StartsWith("~/")) throw new ArgumentException("The rootPath argument cannot be a virtual path and cannot start with '~/'"); + if (rootPath == null) throw new ArgumentNullException(nameof(rootPath)); + if (string.IsNullOrEmpty(rootPath)) throw new ArgumentException("Value can't be empty.", nameof(rootPath)); + if (rootUrl == null) throw new ArgumentNullException(nameof(rootUrl)); + if (string.IsNullOrEmpty(rootUrl)) throw new ArgumentException("Value can't be empty.", nameof(rootUrl)); + if (rootPath.StartsWith("~/")) throw new ArgumentException("Value can't be a virtual path and start with '~/'.", nameof(rootPath)); // rootPath should be... rooted, as in, it's a root path! if (Path.IsPathRooted(rootPath) == false) @@ -314,7 +315,7 @@ namespace Umbraco.Core.IO // nothing prevents us to reach the file, security-wise, yet it is outside // this filesystem's root - throw - throw new FileSecurityException("File '" + opath + "' is outside this filesystem's root."); + throw new UnauthorizedAccessException("File '" + opath + "' is outside this filesystem's root."); } /// diff --git a/src/Umbraco.Core/IO/SystemDirectories.cs b/src/Umbraco.Core/IO/SystemDirectories.cs index 2f33d82bdc..b4688d2e9f 100644 --- a/src/Umbraco.Core/IO/SystemDirectories.cs +++ b/src/Umbraco.Core/IO/SystemDirectories.cs @@ -15,6 +15,8 @@ namespace Umbraco.Core.IO public static string TempFileUploads => TempData + "/FileUploads"; + public static string TempImageUploads => TempFileUploads + "/rte"; + public static string Install => "~/install"; public static string AppCode => "~/App_Code"; @@ -27,13 +29,13 @@ namespace Umbraco.Core.IO public static string MacroPartials => MvcViews + "/MacroPartials/"; - public static string Media => IOHelper.ReturnPath("umbracoMediaPath", "~/media"); + public static string Media => IOHelper.ReturnPath(Constants.AppSettings.MediaPath, "~/media"); - public static string Scripts => IOHelper.ReturnPath("umbracoScriptsPath", "~/scripts"); + public static string Scripts => IOHelper.ReturnPath(Constants.AppSettings.ScriptsPath, "~/scripts"); - public static string Css => IOHelper.ReturnPath("umbracoCssPath", "~/css"); + public static string Css => IOHelper.ReturnPath(Constants.AppSettings.CssPath, "~/css"); - public static string Umbraco => IOHelper.ReturnPath("umbracoPath", "~/umbraco"); + public static string Umbraco => IOHelper.ReturnPath(Constants.AppSettings.Path, "~/umbraco"); public static string Packages => Data + "/packages"; diff --git a/src/Umbraco.Core/Logging/Viewer/ILogViewer.cs b/src/Umbraco.Core/Logging/Viewer/ILogViewer.cs index b39a3f38df..dbdd7842ba 100644 --- a/src/Umbraco.Core/Logging/Viewer/ILogViewer.cs +++ b/src/Umbraco.Core/Logging/Viewer/ILogViewer.cs @@ -42,6 +42,12 @@ namespace Umbraco.Core.Logging.Viewer bool CheckCanOpenLogs(LogTimePeriod logTimePeriod); + /// + /// Gets the current Serilog minimum log level + /// + /// + string GetLogLevel(); + /// /// Returns the collection of logs /// diff --git a/src/Umbraco.Core/Logging/Viewer/LogViewerSourceBase.cs b/src/Umbraco.Core/Logging/Viewer/LogViewerSourceBase.cs index acb2f5dbf9..607c20e601 100644 --- a/src/Umbraco.Core/Logging/Viewer/LogViewerSourceBase.cs +++ b/src/Umbraco.Core/Logging/Viewer/LogViewerSourceBase.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Xml; using Newtonsoft.Json; +using Serilog; using Serilog.Events; using Umbraco.Core.IO; using Umbraco.Core.Models; using Umbraco.Core.Persistence.DatabaseModelDefinitions; +using Formatting = Newtonsoft.Json.Formatting; namespace Umbraco.Core.Logging.Viewer { @@ -89,6 +92,16 @@ namespace Umbraco.Core.Logging.Viewer return errorCounter.Count; } + /// + /// Get the Serilog minimum-level value from the config file. + /// + /// + public string GetLogLevel() + { + var logLevel = Enum.GetValues(typeof(LogEventLevel)).Cast().Where(Log.Logger.IsEnabled)?.Min() ?? null; + return logLevel?.ToString() ?? ""; + } + public LogLevelCounts GetLogLevelCounts(LogTimePeriod logTimePeriod) { var counter = new CountingFilter(); diff --git a/src/Umbraco.Core/MainDom.cs b/src/Umbraco.Core/MainDom.cs deleted file mode 100644 index d1012fb669..0000000000 --- a/src/Umbraco.Core/MainDom.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Web.Hosting; -using Umbraco.Core.Logging; - -namespace Umbraco.Core -{ - /// - /// Provides the full implementation of . - /// - /// - /// When an AppDomain starts, it tries to acquire the main domain status. - /// When an AppDomain stops (eg the application is restarting) it should release the main domain status. - /// - internal class MainDom : IMainDom, IRegisteredObject - { - #region Vars - - private readonly ILogger _logger; - - // our own lock for local consistency - private readonly object _locko = new object(); - - // async lock representing the main domain lock - private readonly AsyncLock _asyncLock; - private IDisposable _asyncLocker; - - // event wait handle used to notify current main domain that it should - // release the lock because a new domain wants to be the main domain - private readonly EventWaitHandle _signal; - - // indicates whether... - private volatile bool _isMainDom; // we are the main domain - private volatile bool _signaled; // we have been signaled - - // actions to run before releasing the main domain - private readonly List> _callbacks = new List>(); - - private const int LockTimeoutMilliseconds = 90000; // (1.5 * 60 * 1000) == 1 min 30 seconds - - #endregion - - #region Ctor - - // initializes a new instance of MainDom - public MainDom(ILogger logger) - { - _logger = logger; - - var appId = string.Empty; - // HostingEnvironment.ApplicationID is null in unit tests, making ReplaceNonAlphanumericChars fail - if (HostingEnvironment.ApplicationID != null) - appId = HostingEnvironment.ApplicationID.ReplaceNonAlphanumericChars(string.Empty); - - // combining with the physical path because if running on eg IIS Express, - // two sites could have the same appId even though they are different. - // - // now what could still collide is... two sites, running in two different processes - // and having the same appId, and running on the same app physical path - // - // we *cannot* use the process ID here because when an AppPool restarts it is - // a new process for the same application path - - var appPath = HostingEnvironment.ApplicationPhysicalPath; - var hash = (appId + ":::" + appPath).ToSHA1(); - - var lockName = "UMBRACO-" + hash + "-MAINDOM-LCK"; - _asyncLock = new AsyncLock(lockName); - - var eventName = "UMBRACO-" + hash + "-MAINDOM-EVT"; - _signal = new EventWaitHandle(false, EventResetMode.AutoReset, eventName); - } - - #endregion - - /// - /// Registers a resource that requires the current AppDomain to be the main domain to function. - /// - /// An action to execute before the AppDomain releases the main domain status. - /// An optional weight (lower goes first). - /// A value indicating whether it was possible to register. - public bool Register(Action release, int weight = 100) - => Register(null, release, weight); - - /// - /// Registers a resource that requires the current AppDomain to be the main domain to function. - /// - /// An action to execute when registering. - /// An action to execute before the AppDomain releases the main domain status. - /// An optional weight (lower goes first). - /// A value indicating whether it was possible to register. - /// If registering is successful, then the action - /// is guaranteed to execute before the AppDomain releases the main domain status. - public bool Register(Action install, Action release, int weight = 100) - { - lock (_locko) - { - if (_signaled) return false; - install?.Invoke(); - if (release != null) - _callbacks.Add(new KeyValuePair(weight, release)); - return true; - } - } - - // handles the signal requesting that the main domain is released - private void OnSignal(string source) - { - // once signaled, we stop waiting, but then there is the hosting environment - // so we have to make sure that we only enter that method once - - lock (_locko) - { - _logger.Debug("Signaled ({Signaled}) ({SignalSource})", _signaled ? "again" : "first", source); - if (_signaled) return; - if (_isMainDom == false) return; // probably not needed - _signaled = true; - } - - try - { - _logger.Info("Stopping ({SignalSource})", source); - foreach (var callback in _callbacks.OrderBy(x => x.Key).Select(x => x.Value)) - { - try - { - callback(); // no timeout on callbacks - } - catch (Exception e) - { - _logger.Error(e, "Error while running callback, remaining callbacks will not run."); - throw; - } - - } - _logger.Debug("Stopped ({SignalSource})", source); - } - finally - { - // in any case... - _isMainDom = false; - _asyncLocker.Dispose(); - _logger.Info("Released ({SignalSource})", source); - } - } - - // acquires the main domain - internal bool Acquire() - { - lock (_locko) // we don't want the hosting environment to interfere by signaling - { - // if signaled, too late to acquire, give up - // the handler is not installed so that would be the hosting environment - if (_signaled) - { - _logger.Info("Cannot acquire (signaled)."); - return false; - } - - _logger.Info("Acquiring."); - - // signal other instances that we want the lock, then wait one the lock, - // which may timeout, and this is accepted - see comments below - - // signal, then wait for the lock, then make sure the event is - // reset (maybe there was noone listening..) - _signal.Set(); - - // if more than 1 instance reach that point, one will get the lock - // and the other one will timeout, which is accepted - - //TODO: This can throw a TimeoutException - in which case should this be in a try/finally to ensure the signal is always reset? - _asyncLocker = _asyncLock.Lock(LockTimeoutMilliseconds); - _isMainDom = true; - - // we need to reset the event, because otherwise we would end up - // signaling ourselves and committing suicide immediately. - // only 1 instance can reach that point, but other instances may - // have started and be trying to get the lock - they will timeout, - // which is accepted - - _signal.Reset(); - - //WaitOneAsync (ext method) will wait for a signal without blocking the main thread, the waiting is done on a background thread - - _signal.WaitOneAsync() - .ContinueWith(_ => OnSignal("signal")); - - HostingEnvironment.RegisterObject(this); - - _logger.Info("Acquired."); - return true; - } - } - - /// - /// Gets a value indicating whether the current domain is the main domain. - /// - public bool IsMainDom => _isMainDom; - - // IRegisteredObject - void IRegisteredObject.Stop(bool immediate) - { - try - { - OnSignal("environment"); // will run once - } - finally - { - HostingEnvironment.UnregisterObject(this); - } - } - } -} diff --git a/src/Umbraco.Core/Manifest/ManifestContentAppFactory.cs b/src/Umbraco.Core/Manifest/ManifestContentAppFactory.cs index 1c50a4b895..788310d7a1 100644 --- a/src/Umbraco.Core/Manifest/ManifestContentAppFactory.cs +++ b/src/Umbraco.Core/Manifest/ManifestContentAppFactory.cs @@ -19,6 +19,8 @@ namespace Umbraco.Core.Manifest // '-content/foo', // hide for content type 'foo' // '+content/*', // show for all other content types // '+media/*', // show for all media types + // '-member/foo' // hide for member type 'foo' + // '+member/*' // show for all member types // '+role/admin' // show for admin users. Role based permissions will override others. // ] // }, @@ -56,6 +58,10 @@ namespace Umbraco.Core.Manifest partA = "media"; partB = media.ContentType.Alias; break; + case IMember member: + partA = "member"; + partB = member.ContentType.Alias; + break; default: return null; diff --git a/src/Umbraco.Core/Manifest/ManifestParser.cs b/src/Umbraco.Core/Manifest/ManifestParser.cs index efd9e92b1f..1ecc738b95 100644 --- a/src/Umbraco.Core/Manifest/ManifestParser.cs +++ b/src/Umbraco.Core/Manifest/ManifestParser.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Text; using Newtonsoft.Json; using Umbraco.Core.Cache; -using Umbraco.Core.Exceptions; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.PropertyEditors; @@ -42,7 +41,8 @@ namespace Umbraco.Core.Manifest _cache = appCaches.RuntimeCache; _validators = validators ?? throw new ArgumentNullException(nameof(validators)); _filters = filters ?? throw new ArgumentNullException(nameof(filters)); - if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullOrEmptyException(nameof(path)); + if (path == null) throw new ArgumentNullException(nameof(path)); + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(path)); Path = path; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -155,8 +155,8 @@ namespace Umbraco.Core.Manifest /// internal PackageManifest ParseManifest(string text) { - if (string.IsNullOrWhiteSpace(text)) - throw new ArgumentNullOrEmptyException(nameof(text)); + if (text == null) throw new ArgumentNullException(nameof(text)); + if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(text)); var manifest = JsonConvert.DeserializeObject(text, new DataEditorConverter(_logger), diff --git a/src/Umbraco.Core/Mapping/UmbracoMapper.cs b/src/Umbraco.Core/Mapping/UmbracoMapper.cs index 8915ebcf74..e62825101c 100644 --- a/src/Umbraco.Core/Mapping/UmbracoMapper.cs +++ b/src/Umbraco.Core/Mapping/UmbracoMapper.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using Umbraco.Core.Exceptions; namespace Umbraco.Core.Mapping { @@ -231,10 +232,10 @@ namespace Umbraco.Core.Mapping if (ctor != null && map != null) { // register (for next time) and do it now (for this time) - object NCtor(object s, MapperContext c) => MapEnumerableInternal((IEnumerable) s, targetGenericArg, ctor, map, c); + object NCtor(object s, MapperContext c) => MapEnumerableInternal((IEnumerable)s, targetGenericArg, ctor, map, c); DefineCtors(sourceType)[targetType] = NCtor; DefineMaps(sourceType)[targetType] = Identity; - return (TTarget) NCtor(source, context); + return (TTarget)NCtor(source, context); } throw new InvalidOperationException($"Don't know how to map {sourceGenericArg.FullName} to {targetGenericArg.FullName}, so don't know how to map {sourceType.FullName} to {targetType.FullName}."); @@ -259,13 +260,13 @@ namespace Umbraco.Core.Mapping if (typeof(TTarget).IsArray) { var elementType = typeof(TTarget).GetElementType(); - if (elementType == null) throw new Exception("panic"); + if (elementType == null) throw new PanicException("elementType == null which should never occur"); var targetArray = Array.CreateInstance(elementType, targetList.Count); targetList.CopyTo(targetArray, 0); target = targetArray; } - return (TTarget) target; + return (TTarget)target; } /// @@ -342,7 +343,21 @@ namespace Umbraco.Core.Mapping if (ctor == null) return null; - _ctors[sourceType] = sourceCtor; + _ctors.AddOrUpdate(sourceType, sourceCtor, (k, v) => + { + // Add missing constructors + foreach (var c in sourceCtor) + { + if (!v.ContainsKey(c.Key)) + { + v.Add(c.Key, c.Value); + } + } + + return v; + }); + + return ctor; } @@ -367,7 +382,17 @@ namespace Umbraco.Core.Mapping if (map == null) return null; - _maps[sourceType] = sourceMap; + if (_maps.ContainsKey(sourceType)) + { + foreach (var m in sourceMap) + { + if (!_maps[sourceType].TryGetValue(m.Key, out _)) + _maps[sourceType].Add(m.Key, m.Value); + } + } + else + _maps[sourceType] = sourceMap; + return map; } @@ -382,7 +407,7 @@ namespace Umbraco.Core.Mapping { if (type.IsArray) return type.GetElementType(); if (type.IsGenericType) return type.GenericTypeArguments[0]; - throw new Exception("panic"); + throw new PanicException($"Could not get enumerable or array type from {type}"); } /// diff --git a/src/Umbraco.Core/Migrations/DataLossException.cs b/src/Umbraco.Core/Migrations/DataLossException.cs deleted file mode 100644 index 6ff332f626..0000000000 --- a/src/Umbraco.Core/Migrations/DataLossException.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; - -namespace Umbraco.Core.Migrations -{ - /// - /// Used if a migration has executed but the whole process has failed and cannot be rolled back - /// - internal class DataLossException : Exception - { - public DataLossException(string msg) - : base(msg) - { - - } - - public DataLossException(string msg, Exception inner) - : base(msg, inner) - { - - } - } -} diff --git a/src/Umbraco.Core/Migrations/Expressions/Delete/DeleteBuilder.cs b/src/Umbraco.Core/Migrations/Expressions/Delete/DeleteBuilder.cs index 9a4f437f62..65c15456a5 100644 --- a/src/Umbraco.Core/Migrations/Expressions/Delete/DeleteBuilder.cs +++ b/src/Umbraco.Core/Migrations/Expressions/Delete/DeleteBuilder.cs @@ -1,4 +1,4 @@ -using Umbraco.Core.Exceptions; +using System; using Umbraco.Core.Migrations.Expressions.Common; using Umbraco.Core.Migrations.Expressions.Delete.Column; using Umbraco.Core.Migrations.Expressions.Delete.Constraint; @@ -39,8 +39,9 @@ namespace Umbraco.Core.Migrations.Expressions.Delete /// public IExecutableBuilder KeysAndIndexes(string tableName, bool local = true, bool foreign = true) { - if (tableName.IsNullOrWhiteSpace()) - throw new ArgumentNullOrEmptyException(nameof(tableName)); + if (tableName == null) throw new ArgumentNullException(nameof(tableName)); + if (string.IsNullOrWhiteSpace(tableName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(tableName)); + return new DeleteKeysAndIndexesBuilder(_context) { TableName = tableName, DeleteLocal = local, DeleteForeign = foreign }; } diff --git a/src/Umbraco.Core/Migrations/Expressions/Delete/KeysAndIndexes/DeleteKeysAndIndexesBuilder.cs b/src/Umbraco.Core/Migrations/Expressions/Delete/KeysAndIndexes/DeleteKeysAndIndexesBuilder.cs index 9b13457b76..df74bf7c87 100644 --- a/src/Umbraco.Core/Migrations/Expressions/Delete/KeysAndIndexes/DeleteKeysAndIndexesBuilder.cs +++ b/src/Umbraco.Core/Migrations/Expressions/Delete/KeysAndIndexes/DeleteKeysAndIndexesBuilder.cs @@ -1,5 +1,7 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using NPoco; +using Umbraco.Core; using Umbraco.Core.Migrations.Expressions.Common; using Umbraco.Core.Persistence.SqlSyntax; @@ -27,31 +29,57 @@ namespace Umbraco.Core.Migrations.Expressions.Delete.KeysAndIndexes { _context.BuildingExpression = false; + //get a list of all constraints - this will include all PK, FK and unique constraints + var tableConstraints = _context.SqlContext.SqlSyntax.GetConstraintsPerTable(_context.Database).DistinctBy(x => x.Item2).ToList(); + + //get a list of defined indexes - this will include all indexes, unique indexes and unique constraint indexes + var indexes = _context.SqlContext.SqlSyntax.GetDefinedIndexesDefinitions(_context.Database).DistinctBy(x => x.IndexName).ToList(); + + var uniqueConstraintNames = tableConstraints.Where(x => !x.Item2.InvariantStartsWith("PK_") && !x.Item2.InvariantStartsWith("FK_")).Select(x => x.Item2); + var indexNames = indexes.Select(x => x.IndexName).ToList(); + // drop keys if (DeleteLocal || DeleteForeign) { // table, constraint - var tableKeys = _context.SqlContext.SqlSyntax.GetConstraintsPerTable(_context.Database).DistinctBy(x => x.Item2).ToList(); + if (DeleteForeign) { - foreach (var key in tableKeys.Where(x => x.Item1 == TableName && x.Item2.StartsWith("FK_"))) + //In some cases not all FK's are prefixed with "FK" :/ mostly with old upgraded databases so we need to check if it's either: + // * starts with FK OR + // * doesn't start with PK_ and doesn't exist in the list of indexes + + foreach (var key in tableConstraints.Where(x => x.Item1 == TableName + && (x.Item2.InvariantStartsWith("FK_") || (!x.Item2.InvariantStartsWith("PK_") && !indexNames.InvariantContains(x.Item2))))) + { Delete.ForeignKey(key.Item2).OnTable(key.Item1).Do(); + } + } if (DeleteLocal) { - foreach (var key in tableKeys.Where(x => x.Item1 == TableName && x.Item2.StartsWith("PK_"))) + foreach (var key in tableConstraints.Where(x => x.Item1 == TableName && x.Item2.InvariantStartsWith("PK_"))) Delete.PrimaryKey(key.Item2).FromTable(key.Item1).Do(); - // note: we do *not* delete the DEFAULT constraints + // note: we do *not* delete the DEFAULT constraints and if we wanted to we'd have to deal with that in interesting ways + // since SQL server has a specific way to handle that, see SqlServerSyntaxProvider.GetDefaultConstraintsPerColumn } } // drop indexes if (DeleteLocal) - { - var indexes = _context.SqlContext.SqlSyntax.GetDefinedIndexesDefinitions(_context.Database).DistinctBy(x => x.IndexName).ToList(); + { foreach (var index in indexes.Where(x => x.TableName == TableName)) - Delete.Index(index.IndexName).OnTable(index.TableName).Do(); + { + //if this is a unique constraint we need to drop the constraint, else drop the index + //to figure this out, the index must be tagged as unique and it must exist in the tableConstraints + + if (index.IsUnique && uniqueConstraintNames.InvariantContains(index.IndexName)) + Delete.UniqueConstraint(index.IndexName).FromTable(index.TableName).Do(); + else + Delete.Index(index.IndexName).OnTable(index.TableName).Do(); + } + } } diff --git a/src/Umbraco.Core/Migrations/IncompleteMigrationExpressionException.cs b/src/Umbraco.Core/Migrations/IncompleteMigrationExpressionException.cs index 91d1838d6f..3c81e2f0e2 100644 --- a/src/Umbraco.Core/Migrations/IncompleteMigrationExpressionException.cs +++ b/src/Umbraco.Core/Migrations/IncompleteMigrationExpressionException.cs @@ -1,28 +1,49 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Core.Migrations { /// - /// Represents errors that occurs when a migration exception is not executed. + /// The exception that is thrown when a migration expression is not executed. /// /// - /// Migration expression such as Alter.Table(...).Do() *must* end with Do() else they are - /// not executed. When a non-executed expression is detected, an IncompleteMigrationExpressionException - /// is thrown. + /// Migration expressions such as Alter.Table(...).Do() must end with Do(), else they are not executed. + /// When a non-executed expression is detected, an IncompleteMigrationExpressionException is thrown. /// + /// + [Serializable] public class IncompleteMigrationExpressionException : Exception { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// public IncompleteMigrationExpressionException() { } /// - /// Initializes a new instance of the class with a message. + /// Initializes a new instance of the class with a message. /// + /// The message that describes the error. public IncompleteMigrationExpressionException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public IncompleteMigrationExpressionException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected IncompleteMigrationExpressionException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } } diff --git a/src/Umbraco.Core/Migrations/Install/DatabaseBuilder.cs b/src/Umbraco.Core/Migrations/Install/DatabaseBuilder.cs index d86c682bd5..d5d8bbab6f 100644 --- a/src/Umbraco.Core/Migrations/Install/DatabaseBuilder.cs +++ b/src/Umbraco.Core/Migrations/Install/DatabaseBuilder.cs @@ -5,7 +5,6 @@ using System.IO; using System.Linq; using System.Xml.Linq; using Umbraco.Core.Configuration; -using Umbraco.Core.Exceptions; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Migrations.Upgrade; @@ -278,8 +277,10 @@ namespace Umbraco.Core.Migrations.Install /// A logger. private static void SaveConnectionString(string connectionString, string providerName, ILogger logger) { - if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentNullOrEmptyException(nameof(connectionString)); - if (string.IsNullOrWhiteSpace(providerName)) throw new ArgumentNullOrEmptyException(nameof(providerName)); + if (connectionString == null) throw new ArgumentNullException(nameof(connectionString)); + if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(connectionString)); + if (providerName == null) throw new ArgumentNullException(nameof(providerName)); + if (string.IsNullOrWhiteSpace(providerName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(providerName)); var fileSource = "web.config"; var fileName = IOHelper.MapPath(SystemDirectories.Root +"/" + fileSource); diff --git a/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs index dd5c17713a..76a8b3a2f4 100644 --- a/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Core/Migrations/Install/DatabaseDataCreator.cs @@ -151,6 +151,8 @@ namespace Umbraco.Core.Migrations.Install _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Domains, Name = "Domains" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.KeyValues, Name = "KeyValues" }); _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Languages, Name = "Languages" }); + + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); } private void CreateContentTypeData() @@ -190,6 +192,7 @@ namespace Umbraco.Core.Migrations.Install _database.Insert(new UserGroup2AppDto { UserGroupId = 1, AppAlias = Constants.Applications.Settings }); _database.Insert(new UserGroup2AppDto { UserGroupId = 1, AppAlias = Constants.Applications.Users }); _database.Insert(new UserGroup2AppDto { UserGroupId = 1, AppAlias = Constants.Applications.Forms }); + _database.Insert(new UserGroup2AppDto { UserGroupId = 1, AppAlias = Constants.Applications.Translation }); _database.Insert(new UserGroup2AppDto { UserGroupId = 2, AppAlias = Constants.Applications.Content }); @@ -226,8 +229,8 @@ namespace Umbraco.Core.Migrations.Install _database.Insert(Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 32, UniqueId = 32.ToGuid(), DataTypeId = Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Constants.Conventions.Member.LastLockoutDate, Name = Constants.Conventions.Member.LastLockoutDateLabel, SortOrder = 4, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte) ContentVariation.Nothing }); _database.Insert(Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 33, UniqueId = 33.ToGuid(), DataTypeId = Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Constants.Conventions.Member.LastLoginDate, Name = Constants.Conventions.Member.LastLoginDateLabel, SortOrder = 5, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte) ContentVariation.Nothing }); _database.Insert(Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 34, UniqueId = 34.ToGuid(), DataTypeId = Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Constants.Conventions.Member.LastPasswordChangeDate, Name = Constants.Conventions.Member.LastPasswordChangeDateLabel, SortOrder = 6, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte) ContentVariation.Nothing }); - _database.Insert(Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 35, UniqueId = 35.ToGuid(), DataTypeId = Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = null, Alias = Constants.Conventions.Member.PasswordQuestion, Name = Constants.Conventions.Member.PasswordQuestionLabel, SortOrder = 7, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); - _database.Insert(Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 36, UniqueId = 36.ToGuid(), DataTypeId = Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = null, Alias = Constants.Conventions.Member.PasswordAnswer, Name = Constants.Conventions.Member.PasswordAnswerLabel, SortOrder = 8, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); + _database.Insert(Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 35, UniqueId = 35.ToGuid(), DataTypeId = Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Constants.Conventions.Member.PasswordQuestion, Name = Constants.Conventions.Member.PasswordQuestionLabel, SortOrder = 7, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); + _database.Insert(Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 36, UniqueId = 36.ToGuid(), DataTypeId = Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Constants.Conventions.Member.PasswordAnswer, Name = Constants.Conventions.Member.PasswordAnswerLabel, SortOrder = 8, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); } @@ -309,14 +312,27 @@ namespace Umbraco.Core.Migrations.Install private void CreateRelationTypeData() { var relationType = new RelationTypeDto { Id = 1, Alias = Constants.Conventions.RelationTypes.RelateDocumentOnCopyAlias, ChildObjectType = Constants.ObjectTypes.Document, ParentObjectType = Constants.ObjectTypes.Document, Dual = true, Name = Constants.Conventions.RelationTypes.RelateDocumentOnCopyName }; - relationType.UniqueId = (relationType.Alias + "____" + relationType.Name).ToGuid(); + relationType.UniqueId = CreateUniqueRelationTypeId(relationType.Alias, relationType.Name); _database.Insert(Constants.DatabaseSchema.Tables.RelationType, "id", false, relationType); relationType = new RelationTypeDto { Id = 2, Alias = Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias, ChildObjectType = Constants.ObjectTypes.Document, ParentObjectType = Constants.ObjectTypes.Document, Dual = false, Name = Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteName }; - relationType.UniqueId = (relationType.Alias + "____" + relationType.Name).ToGuid(); + relationType.UniqueId = CreateUniqueRelationTypeId(relationType.Alias, relationType.Name); _database.Insert(Constants.DatabaseSchema.Tables.RelationType, "id", false, relationType); relationType = new RelationTypeDto { Id = 3, Alias = Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias, ChildObjectType = Constants.ObjectTypes.Media, ParentObjectType = Constants.ObjectTypes.Media, Dual = false, Name = Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteName }; - relationType.UniqueId = (relationType.Alias + "____" + relationType.Name).ToGuid(); + relationType.UniqueId = CreateUniqueRelationTypeId(relationType.Alias, relationType.Name); _database.Insert(Constants.DatabaseSchema.Tables.RelationType, "id", false, relationType); + + relationType = new RelationTypeDto { Id = 4, Alias = Constants.Conventions.RelationTypes.RelatedMediaAlias, ChildObjectType = null, ParentObjectType = null, Dual = false, Name = Constants.Conventions.RelationTypes.RelatedMediaName }; + relationType.UniqueId = CreateUniqueRelationTypeId(relationType.Alias, relationType.Name); + _database.Insert(Constants.DatabaseSchema.Tables.RelationType, "id", false, relationType); + + relationType = new RelationTypeDto { Id = 5, Alias = Constants.Conventions.RelationTypes.RelatedDocumentAlias, ChildObjectType = null, ParentObjectType = null, Dual = false, Name = Constants.Conventions.RelationTypes.RelatedDocumentName }; + relationType.UniqueId = CreateUniqueRelationTypeId(relationType.Alias, relationType.Name); + _database.Insert(Constants.DatabaseSchema.Tables.RelationType, "id", false, relationType); + } + + internal static Guid CreateUniqueRelationTypeId(string alias, string name) + { + return (alias + "____" + name).ToGuid(); } private void CreateKeyValueData() diff --git a/src/Umbraco.Core/Migrations/MigrationPlan.cs b/src/Umbraco.Core/Migrations/MigrationPlan.cs index 37d1a03a5a..89c3c809e8 100644 --- a/src/Umbraco.Core/Migrations/MigrationPlan.cs +++ b/src/Umbraco.Core/Migrations/MigrationPlan.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Scoping; using Type = System.Type; @@ -25,7 +24,9 @@ namespace Umbraco.Core.Migrations /// The name of the plan. public MigrationPlan(string name) { - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); + Name = name; } @@ -43,7 +44,8 @@ namespace Umbraco.Core.Migrations private MigrationPlan Add(string sourceState, string targetState, Type migration) { if (sourceState == null) throw new ArgumentNullException(nameof(sourceState)); - if (string.IsNullOrWhiteSpace(targetState)) throw new ArgumentNullOrEmptyException(nameof(targetState)); + if (targetState == null) throw new ArgumentNullException(nameof(targetState)); + if (string.IsNullOrWhiteSpace(targetState)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(targetState)); if (sourceState == targetState) throw new ArgumentException("Source and target state cannot be identical."); if (migration == null) throw new ArgumentNullException(nameof(migration)); if (!migration.Implements()) throw new ArgumentException($"Type {migration.Name} does not implement IMigration.", nameof(migration)); @@ -135,10 +137,12 @@ namespace Umbraco.Core.Migrations /// public MigrationPlan ToWithClone(string startState, string endState, string targetState) { - if (string.IsNullOrWhiteSpace(startState)) throw new ArgumentNullOrEmptyException(nameof(startState)); - if (string.IsNullOrWhiteSpace(endState)) throw new ArgumentNullOrEmptyException(nameof(endState)); - if (string.IsNullOrWhiteSpace(targetState)) throw new ArgumentNullOrEmptyException(nameof(targetState)); - + if (startState == null) throw new ArgumentNullException(nameof(startState)); + if (string.IsNullOrWhiteSpace(startState)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(startState)); + if (endState == null) throw new ArgumentNullException(nameof(endState)); + if (string.IsNullOrWhiteSpace(endState)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(endState)); + if (targetState == null) throw new ArgumentNullException(nameof(targetState)); + if (string.IsNullOrWhiteSpace(targetState)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(targetState)); if (startState == endState) throw new ArgumentException("Start and end states cannot be identical."); startState = startState.Trim(); @@ -317,7 +321,7 @@ namespace Umbraco.Core.Migrations // throw a raw exception here: this should never happen as the plan has // been validated - this is just a paranoid safety test if (!_transitions.TryGetValue(origState, out transition)) - throw new Exception($"Unknown state \"{origState}\"."); + throw new InvalidOperationException($"Unknown state \"{origState}\"."); } // prepare and de-duplicate post-migrations, only keeping the 1st occurence @@ -339,7 +343,7 @@ namespace Umbraco.Core.Migrations // safety check - again, this should never happen as the plan has been validated, // and this is just a paranoid safety test if (origState != _finalState) - throw new Exception($"Internal error, reached state {origState} which is not final state {_finalState}"); + throw new InvalidOperationException($"Internal error, reached state {origState} which is not final state {_finalState}"); return origState; } @@ -358,7 +362,7 @@ namespace Umbraco.Core.Migrations var states = new List { origState }; if (!_transitions.TryGetValue(origState, out var transition)) - throw new Exception($"Unknown state \"{origState}\"."); + throw new InvalidOperationException($"Unknown state \"{origState}\"."); while (transition != null) { @@ -373,12 +377,12 @@ namespace Umbraco.Core.Migrations } if (!_transitions.TryGetValue(origState, out transition)) - throw new Exception($"Unknown state \"{origState}\"."); + throw new InvalidOperationException($"Unknown state \"{origState}\"."); } // safety check if (origState != (toState ?? _finalState)) - throw new Exception($"Internal error, reached state {origState} which is not state {toState ?? _finalState}"); + throw new InvalidOperationException($"Internal error, reached state {origState} which is not state {toState ?? _finalState}"); return states; } @@ -417,7 +421,7 @@ namespace Umbraco.Core.Migrations public override string ToString() { return MigrationType == typeof(NoopMigration) - ? $"{(SourceState == "" ? "" : SourceState)} --> {TargetState}" + ? $"{(SourceState == string.Empty ? "" : SourceState)} --> {TargetState}" : $"{SourceState} -- ({MigrationType.FullName}) --> {TargetState}"; } } diff --git a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs index e8fd3414ec..302bf21700 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs @@ -6,6 +6,7 @@ using Umbraco.Core.Migrations.Upgrade.Common; using Umbraco.Core.Migrations.Upgrade.V_8_0_0; using Umbraco.Core.Migrations.Upgrade.V_8_0_1; using Umbraco.Core.Migrations.Upgrade.V_8_1_0; +using Umbraco.Core.Migrations.Upgrade.V_8_6_0; namespace Umbraco.Core.Migrations.Upgrade { @@ -182,6 +183,13 @@ namespace Umbraco.Core.Migrations.Upgrade To("{0372A42B-DECF-498D-B4D1-6379E907EB94}"); To("{5B1E0D93-F5A3-449B-84BA-65366B84E2D4}"); + // to 8.6.0... + To("{4759A294-9860-46BC-99F9-B4C975CAE580}"); + To("{0BC866BC-0665-487A-9913-0290BD0169AD}"); + To("{3D67D2C8-5E65-47D0-A9E1-DC2EE0779D6B}"); + To("{EE288A91-531B-4995-8179-1D62D9AA3E2E}"); + To("{2AB29964-02A1-474D-BD6B-72148D2A53A2}"); + //FINAL } } diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/AddTypedLabels.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/AddTypedLabels.cs index 2f20f01728..309f8acbc3 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/AddTypedLabels.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/AddTypedLabels.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Linq; using NPoco; using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.Dtos; @@ -71,10 +72,21 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0 // flip known property types - var intPropertyTypes = new[] { 7, 8, 29 }; - var bigintPropertyTypes = new[] { 9, 26 }; - var dtPropertyTypes = new[] { 32, 33, 34 }; + var labelPropertyTypes = Database.Fetch(Sql() + .Select(x => x.Id, x => x.Alias) + .From() + .Where(x => x.DataTypeId == Constants.DataTypes.LabelString) + ); + var intPropertyAliases = new[] { Constants.Conventions.Media.Width, Constants.Conventions.Media.Height, Constants.Conventions.Member.FailedPasswordAttempts }; + var bigintPropertyAliases = new[] { Constants.Conventions.Media.Bytes }; + var dtPropertyAliases = new[] { Constants.Conventions.Member.LastLockoutDate, Constants.Conventions.Member.LastLoginDate, Constants.Conventions.Member.LastPasswordChangeDate }; + + var intPropertyTypes = labelPropertyTypes.Where(pt => intPropertyAliases.Contains(pt.Alias)).Select(pt => pt.Id).ToArray(); + var bigintPropertyTypes = labelPropertyTypes.Where(pt => bigintPropertyAliases.Contains(pt.Alias)).Select(pt => pt.Id).ToArray(); + var dtPropertyTypes = labelPropertyTypes.Where(pt => dtPropertyAliases.Contains(pt.Alias)).Select(pt => pt.Id).ToArray(); + + Database.Execute(Sql().Update(u => u.Set(x => x.DataTypeId, Constants.DataTypes.LabelInt)).WhereIn(x => x.Id, intPropertyTypes)); Database.Execute(Sql().Update(u => u.Set(x => x.DataTypeId, Constants.DataTypes.LabelInt)).WhereIn(x => x.Id, intPropertyTypes)); Database.Execute(Sql().Update(u => u.Set(x => x.DataTypeId, Constants.DataTypes.LabelBigint)).WhereIn(x => x.Id, bigintPropertyTypes)); Database.Execute(Sql().Update(u => u.Set(x => x.DataTypeId, Constants.DataTypes.LabelDateTime)).WhereIn(x => x.Id, dtPropertyTypes)); diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/ConvertRelatedLinksToMultiUrlPicker.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/ConvertRelatedLinksToMultiUrlPicker.cs index 4802750f4b..1956876402 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/ConvertRelatedLinksToMultiUrlPicker.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/ConvertRelatedLinksToMultiUrlPicker.cs @@ -72,6 +72,7 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0 { var sqlNodeData = Sql() .Select() + .From() .Where(x => x.NodeId == intId); var node = Database.Fetch(sqlNodeData).FirstOrDefault(); diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypeMigration.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypeMigration.cs index 7b2daa99ef..95b272dcb4 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypeMigration.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypeMigration.cs @@ -74,9 +74,18 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0 .From() .Where(x => x.NodeId == group.Key)).First(); + // check for duplicate aliases + var aliases = group.Select(x => x.Alias).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); + if (aliases.Distinct().Count() != aliases.Length) + throw new InvalidOperationException($"Cannot migrate prevalues for datatype id={dataType.NodeId}, editor={dataType.EditorAlias}: duplicate alias."); + + // handle null/empty aliases + int index = 0; + var dictionary = group.ToDictionary(x => string.IsNullOrWhiteSpace(x.Alias) ? index++.ToString() : x.Alias); + // migrate the preValues to configuration var migrator = _preValueMigrators.GetMigrator(dataType.EditorAlias) ?? new DefaultPreValueMigrator(); - var config = migrator.GetConfiguration(dataType.NodeId, dataType.EditorAlias, group.ToDictionary(x => x.Alias, x => x)); + var config = migrator.GetConfiguration(dataType.NodeId, dataType.EditorAlias, dictionary); var json = JsonConvert.SerializeObject(config); // validate - and kill the migration if it fails diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/DefaultPreValueMigrator.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/DefaultPreValueMigrator.cs index 7112679de2..0c8161c9ef 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/DefaultPreValueMigrator.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/DefaultPreValueMigrator.cs @@ -24,8 +24,8 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0.DataTypes } // assuming we don't want to fall back to array - if (aliases.Length != preValuesA.Count || aliases.Any(string.IsNullOrWhiteSpace)) - throw new InvalidOperationException($"Cannot migrate datatype w/ id={dataTypeId} preValues: duplicate or null/empty alias."); + if (aliases.Any(string.IsNullOrWhiteSpace)) + throw new InvalidOperationException($"Cannot migrate prevalues for datatype id={dataTypeId}, editor={editorAlias}: null/empty alias."); // dictionary-base prevalues return GetPreValues(preValuesA).ToDictionary(x => x.Alias, GetPreValueValue); diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/MarkdownEditorPreValueMigrator.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/MarkdownEditorPreValueMigrator.cs new file mode 100644 index 0000000000..87a1fd6504 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/MarkdownEditorPreValueMigrator.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0.DataTypes +{ + class MarkdownEditorPreValueMigrator : DefaultPreValueMigrator //PreValueMigratorBase + { + public override bool CanMigrate(string editorAlias) + => editorAlias == Constants.PropertyEditors.Aliases.MarkdownEditor; + + protected override object GetPreValueValue(PreValueDto preValue) + { + if (preValue.Alias == "preview") + return preValue.Value == "1"; + + return base.GetPreValueValue(preValue); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/PreValueMigratorComposer.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/PreValueMigratorComposer.cs index 8dfa464508..db9021d653 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/PreValueMigratorComposer.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/PreValueMigratorComposer.cs @@ -20,7 +20,8 @@ public class PreValueMigratorComposer : ICoreComposer .Append() .Append() .Append() - .Append(); + .Append() + .Append(); } } } diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/RenamingPreValueMigrator.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/RenamingPreValueMigrator.cs index c04e7c8fda..89a71fdaf4 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/RenamingPreValueMigrator.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/DataTypes/RenamingPreValueMigrator.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Umbraco.Core.Exceptions; namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0.DataTypes { @@ -20,7 +21,7 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0.DataTypes case "Umbraco.NoEdit": return Constants.PropertyEditors.Aliases.Label; default: - throw new Exception("panic"); + throw new PanicException($"The alias {editorAlias} is not supported"); } } } diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/RefactorMacroColumns.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/RefactorMacroColumns.cs index bd08b53877..58ec0e30c2 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/RefactorMacroColumns.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/RefactorMacroColumns.cs @@ -15,21 +15,17 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0 { //special trick to add the column without constraints and return the sql to add them later AddColumn("macroType", out var sqls1); - //now we need to update the new column with some values because this column doesn't allow NULL values - Update.Table(Constants.DatabaseSchema.Tables.Macro).Set(new { macroType = (int)MacroTypes.Unknown}).AllRows().Do(); - //now apply constraints (NOT NULL) to new table - foreach (var sql in sqls1) Execute.Sql(sql).Do(); - - //special trick to add the column without constraints and return the sql to add them later AddColumn("macroSource", out var sqls2); - //populate the new macroSource column with legacy data - Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = macroXSLT, macroType = {(int)MacroTypes.Unknown} WHERE macroXSLT IS NOT NULL").Do(); - Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = macroScriptAssembly, macroType = {(int)MacroTypes.Unknown} WHERE macroScriptAssembly IS NOT NULL").Do(); - Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = macroScriptType, macroType = {(int)MacroTypes.Unknown} WHERE macroScriptType IS NOT NULL").Do(); - Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = macroPython, macroType = {(int)MacroTypes.PartialView} WHERE macroPython IS NOT NULL").Do(); + //populate the new columns with legacy data + Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = '', macroType = {(int)MacroTypes.Unknown}").Do(); + Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = macroXSLT, macroType = {(int)MacroTypes.Unknown} WHERE macroXSLT != '' AND macroXSLT IS NOT NULL").Do(); + Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = macroScriptAssembly, macroType = {(int)MacroTypes.Unknown} WHERE macroScriptAssembly != '' AND macroScriptAssembly IS NOT NULL").Do(); + Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = macroScriptType, macroType = {(int)MacroTypes.Unknown} WHERE macroScriptType != '' AND macroScriptType IS NOT NULL").Do(); + Execute.Sql($"UPDATE {Constants.DatabaseSchema.Tables.Macro} SET macroSource = macroPython, macroType = {(int)MacroTypes.PartialView} WHERE macroPython != '' AND macroPython IS NOT NULL").Do(); //now apply constraints (NOT NULL) to new table + foreach (var sql in sqls1) Execute.Sql(sql).Do(); foreach (var sql in sqls2) Execute.Sql(sql).Do(); //now remove these old columns diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/SuperZero.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/SuperZero.cs index 64ac20d175..9026f15fc1 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/SuperZero.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/SuperZero.cs @@ -30,6 +30,7 @@ Database.Execute("set identity_insert umbracoUser off;"); Database.Execute("update umbracoUser2UserGroup set userId=-1 where userId=0;"); + Database.Execute("update umbracoUser2NodeNotify set userId=-1 where userId=0;"); Database.Execute("update umbracoNode set nodeUser=-1 where nodeUser=0;"); Database.Execute("update umbracoUserLogin set userId=-1 where userId=0;"); Database.Execute($"update {Constants.DatabaseSchema.Tables.ContentVersion} set userId=-1 where userId=0;"); diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_1_0/ConvertTinyMceAndGridMediaUrlsToLocalLink.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_1_0/ConvertTinyMceAndGridMediaUrlsToLocalLink.cs index 1b6597a660..b68aa23d78 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/V_8_1_0/ConvertTinyMceAndGridMediaUrlsToLocalLink.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_1_0/ConvertTinyMceAndGridMediaUrlsToLocalLink.cs @@ -50,10 +50,10 @@ namespace Umbraco.Core.Migrations.Upgrade.V_8_1_0 var obj = JsonConvert.DeserializeObject(value); var allControls = obj.SelectTokens("$.sections..rows..areas..controls"); - foreach (var control in allControls.SelectMany(c => c)) + foreach (var control in allControls.SelectMany(c => c).OfType()) { var controlValue = control["value"]; - if (controlValue.Type == JTokenType.String) + if (controlValue?.Type == JTokenType.String) { control["value"] = UpdateMediaUrls(mediaLinkPattern, controlValue.Value()); } diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddMainDomLock.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddMainDomLock.cs new file mode 100644 index 0000000000..6ca493ac7e --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddMainDomLock.cs @@ -0,0 +1,16 @@ +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0 +{ + public class AddMainDomLock : MigrationBase + { + public AddMainDomLock(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + Database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddNewRelationTypes.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddNewRelationTypes.cs new file mode 100644 index 0000000000..2e2e00a9bc --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddNewRelationTypes.cs @@ -0,0 +1,33 @@ +using Umbraco.Core.Migrations.Install; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0 +{ + /// + /// Ensures the new relation types are created + /// + public class AddNewRelationTypes : MigrationBase + { + public AddNewRelationTypes(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + CreateRelation( + Constants.Conventions.RelationTypes.RelatedMediaAlias, + Constants.Conventions.RelationTypes.RelatedMediaName); + + CreateRelation( + Constants.Conventions.RelationTypes.RelatedDocumentAlias, + Constants.Conventions.RelationTypes.RelatedDocumentName); + } + + private void CreateRelation(string alias, string name) + { + var uniqueId = DatabaseDataCreator.CreateUniqueRelationTypeId(alias ,name); //this is the same as how it installs so everything is consistent + Insert.IntoTable(Constants.DatabaseSchema.Tables.RelationType) + .Row(new { typeUniqueId = uniqueId, dual = 0, name, alias }) + .Do(); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddPropertyTypeValidationMessageColumns.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddPropertyTypeValidationMessageColumns.cs new file mode 100644 index 0000000000..f44695da69 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/AddPropertyTypeValidationMessageColumns.cs @@ -0,0 +1,21 @@ +using System.Linq; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0 +{ + + public class AddPropertyTypeValidationMessageColumns : MigrationBase + { + public AddPropertyTypeValidationMessageColumns(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToList(); + + AddColumnIfNotExists(columns, "mandatoryMessage"); + AddColumnIfNotExists(columns, "validationRegExpMessage"); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/MissingContentVersionsIndexes.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/MissingContentVersionsIndexes.cs new file mode 100644 index 0000000000..75de01dd7f --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/MissingContentVersionsIndexes.cs @@ -0,0 +1,24 @@ +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0 +{ + public class MissingContentVersionsIndexes : MigrationBase + { + public MissingContentVersionsIndexes(IMigrationContext context) : base(context) + { + } + + public override void Migrate() + { + Create + .Index("IX_" + ContentVersionDto.TableName + "_NodeId") + .OnTable(ContentVersionDto.TableName) + .OnColumn("nodeId") + .Ascending() + .OnColumn("current") + .Ascending() + .WithOptions().NonClustered() + .Do(); + } + } +} diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/UpdateRelationTypeTable.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/UpdateRelationTypeTable.cs new file mode 100644 index 0000000000..c79f43d20f --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_6_0/UpdateRelationTypeTable.cs @@ -0,0 +1,38 @@ +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_6_0 +{ + + public class UpdateRelationTypeTable : MigrationBase + { + public UpdateRelationTypeTable(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + + Alter.Table(Constants.DatabaseSchema.Tables.RelationType).AlterColumn("parentObjectType").AsGuid().Nullable().Do(); + Alter.Table(Constants.DatabaseSchema.Tables.RelationType).AlterColumn("childObjectType").AsGuid().Nullable().Do(); + + //TODO: We have to update this field to ensure it's not null, we can just copy across the name since that is not nullable + + //drop index before we can alter the column + if (IndexExists("IX_umbracoRelationType_alias")) + Delete + .Index("IX_umbracoRelationType_alias") + .OnTable(Constants.DatabaseSchema.Tables.RelationType) + .Do(); + //change the column to non nullable + Alter.Table(Constants.DatabaseSchema.Tables.RelationType).AlterColumn("alias").AsString(100).NotNullable().Do(); + //re-create the index + Create + .Index("IX_umbracoRelationType_alias") + .OnTable(Constants.DatabaseSchema.Tables.RelationType) + .OnColumn("alias") + .Ascending() + .WithOptions().Unique().WithOptions().NonClustered() + .Do(); + } + } +} diff --git a/src/Umbraco.Core/Models/ContentBase.cs b/src/Umbraco.Core/Models/ContentBase.cs index fbb68194b7..d02ea82012 100644 --- a/src/Umbraco.Core/Models/ContentBase.cs +++ b/src/Umbraco.Core/Models/ContentBase.cs @@ -4,8 +4,6 @@ using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using System.Runtime.Serialization; -using Umbraco.Core.Composing; -using Umbraco.Core.Exceptions; using Umbraco.Core.Models.Entities; namespace Umbraco.Core.Models @@ -229,8 +227,8 @@ namespace Umbraco.Core.Models private void ClearCultureInfo(string culture) { - if (culture.IsNullOrWhiteSpace()) - throw new ArgumentNullOrEmptyException(nameof(culture)); + if (culture == null) throw new ArgumentNullException(nameof(culture)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(culture)); if (_cultureInfos == null) return; _cultureInfos.Remove(culture); diff --git a/src/Umbraco.Core/Models/ContentCultureInfos.cs b/src/Umbraco.Core/Models/ContentCultureInfos.cs index c8c4bea56a..2f9c08b985 100644 --- a/src/Umbraco.Core/Models/ContentCultureInfos.cs +++ b/src/Umbraco.Core/Models/ContentCultureInfos.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Umbraco.Core.Exceptions; using Umbraco.Core.Models.Entities; namespace Umbraco.Core.Models @@ -18,7 +17,9 @@ namespace Umbraco.Core.Models /// public ContentCultureInfos(string culture) { - if (culture.IsNullOrWhiteSpace()) throw new ArgumentNullOrEmptyException(nameof(culture)); + if (culture == null) throw new ArgumentNullException(nameof(culture)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(culture)); + Culture = culture; } diff --git a/src/Umbraco.Core/Models/ContentCultureInfosCollection.cs b/src/Umbraco.Core/Models/ContentCultureInfosCollection.cs index 52f6f9adb6..9bc78fc56d 100644 --- a/src/Umbraco.Core/Models/ContentCultureInfosCollection.cs +++ b/src/Umbraco.Core/Models/ContentCultureInfosCollection.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Generic; using System.Collections.Specialized; using Umbraco.Core.Collections; -using Umbraco.Core.Exceptions; namespace Umbraco.Core.Models { @@ -23,7 +21,9 @@ namespace Umbraco.Core.Models /// public void AddOrUpdate(string culture, string name, DateTime date) { - if (culture.IsNullOrWhiteSpace()) throw new ArgumentNullOrEmptyException(nameof(culture)); + if (culture == null) throw new ArgumentNullException(nameof(culture)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(culture)); + culture = culture.ToLowerInvariant(); if (TryGetValue(culture, out var item)) diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentApp.cs b/src/Umbraco.Core/Models/ContentEditing/ContentApp.cs index bf28c28c9e..64e4b41186 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentApp.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentApp.cs @@ -67,6 +67,12 @@ namespace Umbraco.Core.Models.ContentEditing /// [DataMember(Name = "active")] public bool Active { get; set; } + + /// + /// Gets or sets the content app badge. + /// + [DataMember(Name = "badge")] + public ContentAppBadge Badge { get; set; } } } diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentAppBadge.cs b/src/Umbraco.Core/Models/ContentEditing/ContentAppBadge.cs new file mode 100644 index 0000000000..ba11fd338d --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentAppBadge.cs @@ -0,0 +1,39 @@ +namespace Umbraco.Core.Models.ContentEditing +{ + using System.Runtime.Serialization; + + using Umbraco.Core.Events; + + /// + /// Represents a content app badge + /// + [DataContract(Name = "badge", Namespace = "")] + public class ContentAppBadge + { + /// + /// Initializes a new instance of the class. + /// + public ContentAppBadge() + { + this.Type = ContentAppBadgeType.Default; + } + + /// + /// Gets or sets the number displayed in the badge + /// + [DataMember(Name = "count")] + public int Count { get; set; } + + /// + /// Gets or sets the type of badge to display + /// + /// + /// This controls the background color of the badge. + /// Warning will display a dark yellow badge + /// Alert will display a red badge + /// Default will display a turquoise badge + /// + [DataMember(Name = "type")] + public ContentAppBadgeType Type { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentAppBadgeType.cs b/src/Umbraco.Core/Models/ContentEditing/ContentAppBadgeType.cs new file mode 100644 index 0000000000..c3217099b6 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentAppBadgeType.cs @@ -0,0 +1,24 @@ +namespace Umbraco.Core.Models.ContentEditing +{ + using System.Runtime.Serialization; + + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + /// + /// Represent the content app badge types + /// + [DataContract(Name = "contentAppBadgeType")] + [JsonConverter(typeof(StringEnumConverter))] + public enum ContentAppBadgeType + { + [EnumMember(Value = "default")] + Default = 0, + + [EnumMember(Value = "warning")] + Warning = 1, + + [EnumMember(Value = "alert")] + Alert = 2 + } +} diff --git a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs index bf7228ca47..af8c781072 100644 --- a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs +++ b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Umbraco.Core.Exceptions; namespace Umbraco.Core.Models { @@ -103,11 +102,11 @@ namespace Umbraco.Core.Models public static void SetPublishInfo(this IContent content, string culture, string name, DateTime date) { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentNullOrEmptyException(nameof(name)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); - if (culture.IsNullOrWhiteSpace()) - throw new ArgumentNullOrEmptyException(nameof(culture)); + if (culture == null) throw new ArgumentNullException(nameof(culture)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(culture)); content.PublishCultureInfos.AddOrUpdate(culture, name, date); } @@ -153,11 +152,11 @@ namespace Umbraco.Core.Models public static void SetCultureInfo(this IContentBase content, string culture, string name, DateTime date) { - if (name.IsNullOrWhiteSpace()) - throw new ArgumentNullOrEmptyException(nameof(name)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); - if (culture.IsNullOrWhiteSpace()) - throw new ArgumentNullOrEmptyException(nameof(culture)); + if (culture == null) throw new ArgumentNullException(nameof(culture)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(culture)); content.CultureInfos.AddOrUpdate(culture, name, date); } @@ -222,7 +221,13 @@ namespace Umbraco.Core.Models return true; } - public static void UnpublishCulture(this IContent content, string culture = "*") + /// + /// Returns false if the culture is already unpublished + /// + /// + /// + /// + public static bool UnpublishCulture(this IContent content, string culture = "*") { culture = culture.NullOrWhiteSpaceAsNull(); @@ -230,16 +235,31 @@ namespace Umbraco.Core.Models if (!content.ContentType.SupportsPropertyVariation(culture, "*", true)) throw new NotSupportedException($"Culture \"{culture}\" is not supported by content type \"{content.ContentType.Alias}\" with variation \"{content.ContentType.Variations}\"."); - if (culture == "*") // all cultures + + var keepProcessing = true; + + if (culture == "*") + { + // all cultures content.ClearPublishInfos(); - else // one single culture - content.ClearPublishInfo(culture); + } + else + { + // one single culture + keepProcessing = content.ClearPublishInfo(culture); + } + - // property.PublishValues only publishes what is valid, variation-wise - foreach (var property in content.Properties) - property.UnpublishValues(culture); + if (keepProcessing) + { + // property.PublishValues only publishes what is valid, variation-wise + foreach (var property in content.Properties) + property.UnpublishValues(culture); - content.PublishedState = PublishedState.Publishing; + content.PublishedState = PublishedState.Publishing; + } + + return keepProcessing; } public static void ClearPublishInfos(this IContent content) @@ -247,15 +267,24 @@ namespace Umbraco.Core.Models content.PublishCultureInfos = null; } - public static void ClearPublishInfo(this IContent content, string culture) + /// + /// Returns false if the culture is already unpublished + /// + /// + /// + /// + public static bool ClearPublishInfo(this IContent content, string culture) { - if (culture.IsNullOrWhiteSpace()) - throw new ArgumentNullOrEmptyException(nameof(culture)); + if (culture == null) throw new ArgumentNullException(nameof(culture)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(culture)); - content.PublishCultureInfos.Remove(culture); - - // set the culture to be dirty - it's been modified - content.TouchCulture(culture); + var removed = content.PublishCultureInfos.Remove(culture); + if (removed) + { + // set the culture to be dirty - it's been modified + content.TouchCulture(culture); + } + return removed; } /// diff --git a/src/Umbraco.Core/Models/Editors/ContentPropertyFile.cs b/src/Umbraco.Core/Models/Editors/ContentPropertyFile.cs index ac236e1fdd..225e29a8a1 100644 --- a/src/Umbraco.Core/Models/Editors/ContentPropertyFile.cs +++ b/src/Umbraco.Core/Models/Editors/ContentPropertyFile.cs @@ -1,5 +1,6 @@ namespace Umbraco.Core.Models.Editors { + /// /// Represents an uploaded file for a property. /// diff --git a/src/Umbraco.Core/Models/Editors/UmbracoEntityReference.cs b/src/Umbraco.Core/Models/Editors/UmbracoEntityReference.cs new file mode 100644 index 0000000000..31d48e60cf --- /dev/null +++ b/src/Umbraco.Core/Models/Editors/UmbracoEntityReference.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; + +namespace Umbraco.Core.Models.Editors +{ + /// + /// Used to track reference to other entities in a property value + /// + public struct UmbracoEntityReference : IEquatable + { + private static readonly UmbracoEntityReference _empty = new UmbracoEntityReference(Udi.UnknownTypeUdi.Instance, string.Empty); + + public UmbracoEntityReference(Udi udi, string relationTypeAlias) + { + Udi = udi ?? throw new ArgumentNullException(nameof(udi)); + RelationTypeAlias = relationTypeAlias ?? throw new ArgumentNullException(nameof(relationTypeAlias)); + } + + public UmbracoEntityReference(Udi udi) + { + Udi = udi ?? throw new ArgumentNullException(nameof(udi)); + + switch (udi.EntityType) + { + case Constants.UdiEntityType.Media: + RelationTypeAlias = Constants.Conventions.RelationTypes.RelatedMediaAlias; + break; + default: + RelationTypeAlias = Constants.Conventions.RelationTypes.RelatedDocumentAlias; + break; + } + } + + public static UmbracoEntityReference Empty() => _empty; + + public static bool IsEmpty(UmbracoEntityReference reference) => reference == Empty(); + + public Udi Udi { get; } + public string RelationTypeAlias { get; } + + public override bool Equals(object obj) + { + return obj is UmbracoEntityReference reference && Equals(reference); + } + + public bool Equals(UmbracoEntityReference other) + { + return EqualityComparer.Default.Equals(Udi, other.Udi) && + RelationTypeAlias == other.RelationTypeAlias; + } + + public override int GetHashCode() + { + var hashCode = -487348478; + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Udi); + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(RelationTypeAlias); + return hashCode; + } + + public static bool operator ==(UmbracoEntityReference left, UmbracoEntityReference right) + { + return left.Equals(right); + } + + public static bool operator !=(UmbracoEntityReference left, UmbracoEntityReference right) + { + return !(left == right); + } + } +} diff --git a/src/Umbraco.Core/Models/Entities/EntitySlim.cs b/src/Umbraco.Core/Models/Entities/EntitySlim.cs index b095965056..b4b09b58c2 100644 --- a/src/Umbraco.Core/Models/Entities/EntitySlim.cs +++ b/src/Umbraco.Core/Models/Entities/EntitySlim.cs @@ -66,7 +66,7 @@ namespace Umbraco.Core.Models.Entities public int ParentId { get; set; } /// - public void SetParent(ITreeEntity parent) => throw new WontImplementException(); + public void SetParent(ITreeEntity parent) => throw new InvalidOperationException("This property won't be implemented."); /// [DataMember] @@ -116,7 +116,7 @@ namespace Umbraco.Core.Models.Entities /// public object DeepClone() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } #endregion @@ -128,47 +128,47 @@ namespace Umbraco.Core.Models.Entities public bool IsDirty() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } public bool IsPropertyDirty(string propName) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } public IEnumerable GetDirtyProperties() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } public void ResetDirtyProperties() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } public bool WasDirty() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } public bool WasPropertyDirty(string propertyName) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } public void ResetWereDirtyProperties() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } public void ResetDirtyProperties(bool rememberDirty) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } public IEnumerable GetWereDirtyProperties() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } #endregion diff --git a/src/Umbraco.Core/Models/Entities/IMemberEntitySlim.cs b/src/Umbraco.Core/Models/Entities/IMemberEntitySlim.cs new file mode 100644 index 0000000000..050a999cc2 --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/IMemberEntitySlim.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Core.Models.Entities +{ + public interface IMemberEntitySlim : IContentEntitySlim + { + + } +} diff --git a/src/Umbraco.Core/Models/Entities/ITreeEntity.cs b/src/Umbraco.Core/Models/Entities/ITreeEntity.cs index afa3399202..ab63e1e1d8 100644 --- a/src/Umbraco.Core/Models/Entities/ITreeEntity.cs +++ b/src/Umbraco.Core/Models/Entities/ITreeEntity.cs @@ -24,7 +24,7 @@ /// Sets the parent entity. /// /// Use this method to set the parent entity when the parent entity is known, but has not - /// been persistent and does not yet have an identity. The parent identifier will we retrieved + /// been persistent and does not yet have an identity. The parent identifier will be retrieved /// from the parent entity when needed. If the parent entity still does not have an entity by that /// time, an exception will be thrown by getter. void SetParent(ITreeEntity parent); @@ -53,4 +53,4 @@ /// bool Trashed { get; } } -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/Models/Entities/MemberEntitySlim.cs b/src/Umbraco.Core/Models/Entities/MemberEntitySlim.cs new file mode 100644 index 0000000000..338f363856 --- /dev/null +++ b/src/Umbraco.Core/Models/Entities/MemberEntitySlim.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Core.Models.Entities +{ + public class MemberEntitySlim : ContentEntitySlim, IMemberEntitySlim + { + } +} diff --git a/src/Umbraco.Core/Models/IContentTypeBase.cs b/src/Umbraco.Core/Models/IContentTypeBase.cs index 5f1fe6ed49..ed87c5f320 100644 --- a/src/Umbraco.Core/Models/IContentTypeBase.cs +++ b/src/Umbraco.Core/Models/IContentTypeBase.cs @@ -101,7 +101,7 @@ namespace Umbraco.Core.Models PropertyGroupCollection PropertyGroups { get; set; } /// - /// Gets all local property types belonging to a group, across all local property groups. + /// Gets all local property types all local property groups or ungrouped. /// IEnumerable PropertyTypes { get; } diff --git a/src/Umbraco.Core/Models/IRelation.cs b/src/Umbraco.Core/Models/IRelation.cs index 745216fba1..6bd348d72f 100644 --- a/src/Umbraco.Core/Models/IRelation.cs +++ b/src/Umbraco.Core/Models/IRelation.cs @@ -1,4 +1,5 @@ -using System.Runtime.Serialization; +using System; +using System.Runtime.Serialization; using Umbraco.Core.Models.Entities; namespace Umbraco.Core.Models @@ -11,12 +12,18 @@ namespace Umbraco.Core.Models [DataMember] int ParentId { get; set; } + [DataMember] + Guid ParentObjectType { get; set; } + /// /// Gets or sets the Child Id of the Relation (Destination) /// [DataMember] int ChildId { get; set; } + [DataMember] + Guid ChildObjectType { get; set; } + /// /// Gets or sets the for the Relation /// diff --git a/src/Umbraco.Core/Models/IRelationType.cs b/src/Umbraco.Core/Models/IRelationType.cs index 8bbe657427..9253fae8ab 100644 --- a/src/Umbraco.Core/Models/IRelationType.cs +++ b/src/Umbraco.Core/Models/IRelationType.cs @@ -29,13 +29,13 @@ namespace Umbraco.Core.Models /// /// Corresponds to the NodeObjectType in the umbracoNode table [DataMember] - Guid ParentObjectType { get; set; } + Guid? ParentObjectType { get; set; } /// /// Gets or sets the Childs object type id /// /// Corresponds to the NodeObjectType in the umbracoNode table [DataMember] - Guid ChildObjectType { get; set; } + Guid? ChildObjectType { get; set; } } } diff --git a/src/Umbraco.Core/Models/MediaExtensions.cs b/src/Umbraco.Core/Models/MediaExtensions.cs index 1166698adb..96f183b6e6 100644 --- a/src/Umbraco.Core/Models/MediaExtensions.cs +++ b/src/Umbraco.Core/Models/MediaExtensions.cs @@ -1,10 +1,8 @@ -using System; -using System.Linq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Linq; +using Umbraco.Core.Composing; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Logging; -using Umbraco.Core.PropertyEditors.ValueConverters; +using Umbraco.Core.PropertyEditors; namespace Umbraco.Core.Models { @@ -18,29 +16,12 @@ namespace Umbraco.Core.Models if (!media.Properties.TryGetValue(propertyAlias, out var property)) return string.Empty; - // TODO: would need to be adjusted to variations, when media become variants - if (!(property.GetValue() is string jsonString)) - return string.Empty; - - if (property.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.UploadField) - return jsonString; - - if (property.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.ImageCropper) + if (Current.PropertyEditors.TryGet(property.PropertyType.PropertyEditorAlias, out var editor) + && editor is IDataEditorWithMediaPath dataEditor) { - if (jsonString.DetectIsJson() == false) - return jsonString; - - try - { - var json = JsonConvert.DeserializeObject(jsonString); - if (json["src"] != null) - return json["src"].Value(); - } - catch (Exception ex) - { - logger.Error(ex, "Could not parse the string '{JsonString}' to a json object", jsonString); - return string.Empty; - } + // TODO: would need to be adjusted to variations, when media become variants + var value = property.GetValue(); + return dataEditor.GetMediaPath(value); } // Without knowing what it is, just adding a string here might not be very nice diff --git a/src/Umbraco.Core/Models/MediaTypeExtensions.cs b/src/Umbraco.Core/Models/MediaTypeExtensions.cs new file mode 100644 index 0000000000..4e2ae5822a --- /dev/null +++ b/src/Umbraco.Core/Models/MediaTypeExtensions.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Core.Models +{ + internal static class MediaTypeExtensions + { + internal static bool IsSystemMediaType(this IMediaType mediaType) => + mediaType.Alias == Constants.Conventions.MediaTypes.File + || mediaType.Alias == Constants.Conventions.MediaTypes.Folder + || mediaType.Alias == Constants.Conventions.MediaTypes.Image; + } +} diff --git a/src/Umbraco.Core/Models/Member.cs b/src/Umbraco.Core/Models/Member.cs index b473a154f1..49e07a486d 100644 --- a/src/Umbraco.Core/Models/Member.cs +++ b/src/Umbraco.Core/Models/Member.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Runtime.Serialization; using Umbraco.Core.Composing; -using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; namespace Umbraco.Core.Models @@ -43,7 +42,8 @@ namespace Umbraco.Core.Models public Member(string name, IMemberType contentType) : base(name, -1, contentType, new PropertyCollection()) { - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); IsApproved = true; @@ -63,9 +63,12 @@ namespace Umbraco.Core.Models public Member(string name, string email, string username, IMemberType contentType, bool isApproved = true) : base(name, -1, contentType, new PropertyCollection()) { - if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullOrEmptyException(nameof(email)); - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); - if (string.IsNullOrWhiteSpace(username)) throw new ArgumentNullOrEmptyException(nameof(username)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); + if (email == null) throw new ArgumentNullException(nameof(email)); + if (string.IsNullOrWhiteSpace(email)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(email)); + if (username == null) throw new ArgumentNullException(nameof(username)); + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(username)); _email = email; _username = username; diff --git a/src/Umbraco.Core/Models/Membership/UserGroupExtensions.cs b/src/Umbraco.Core/Models/Membership/UserGroupExtensions.cs index 3903fe405b..b1d0189c56 100644 --- a/src/Umbraco.Core/Models/Membership/UserGroupExtensions.cs +++ b/src/Umbraco.Core/Models/Membership/UserGroupExtensions.cs @@ -15,6 +15,12 @@ namespace Umbraco.Core.Models.Membership return new ReadOnlyUserGroup(group.Id, group.Name, group.Icon, group.StartContentId, group.StartMediaId, group.Alias, group.AllowedSections, group.Permissions); } + public static bool IsSystemUserGroup(this IUserGroup group) => + IsSystemUserGroup(group.Alias); + + public static bool IsSystemUserGroup(this IReadOnlyUserGroup group) => + IsSystemUserGroup(group.Alias); + public static IReadOnlyUserGroup ToReadOnlyGroup(this UserGroupDto group) { return new ReadOnlyUserGroup(group.Id, group.Name, group.Icon, @@ -22,5 +28,12 @@ namespace Umbraco.Core.Models.Membership group.UserGroup2AppDtos.Select(x => x.AppAlias).ToArray(), group.DefaultPermissions == null ? Enumerable.Empty() : group.DefaultPermissions.ToCharArray().Select(x => x.ToString())); } + + private static bool IsSystemUserGroup(this string groupAlias) + { + return groupAlias == Constants.Security.AdminGroupAlias + || groupAlias == Constants.Security.SensitiveDataGroupAlias + || groupAlias == Constants.Security.TranslatorGroupAlias; + } } } diff --git a/src/Umbraco.Core/Models/ObjectTypes.cs b/src/Umbraco.Core/Models/ObjectTypes.cs index dd943ee02b..2ddbdca77a 100644 --- a/src/Umbraco.Core/Models/ObjectTypes.cs +++ b/src/Umbraco.Core/Models/ObjectTypes.cs @@ -41,7 +41,7 @@ namespace Umbraco.Core.Models /// public static UmbracoObjectTypes GetUmbracoObjectType(string name) { - return (UmbracoObjectTypes) Enum.Parse(typeof (UmbracoObjectTypes), name, false); + return (UmbracoObjectTypes) Enum.Parse(typeof (UmbracoObjectTypes), name, true); } #region Guid object type utilities diff --git a/src/Umbraco.Core/Models/PagedResult.cs b/src/Umbraco.Core/Models/PagedResult.cs index ef4d4efdfd..4119751eb3 100644 --- a/src/Umbraco.Core/Models/PagedResult.cs +++ b/src/Umbraco.Core/Models/PagedResult.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Runtime.Serialization; namespace Umbraco.Core.Models @@ -9,7 +8,7 @@ namespace Umbraco.Core.Models /// /// [DataContract(Name = "pagedCollection", Namespace = "")] - public class PagedResult + public abstract class PagedResult { public PagedResult(long totalItems, long pageNumber, long pageSize) { @@ -39,9 +38,6 @@ namespace Umbraco.Core.Models [DataMember(Name = "totalItems")] public long TotalItems { get; private set; } - [DataMember(Name = "items")] - public IEnumerable Items { get; set; } - /// /// Calculates the skip size based on the paged parameters specified /// diff --git a/src/Umbraco.Core/Models/PagedResultOfT.cs b/src/Umbraco.Core/Models/PagedResultOfT.cs new file mode 100644 index 0000000000..efb68863dd --- /dev/null +++ b/src/Umbraco.Core/Models/PagedResultOfT.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace Umbraco.Core.Models +{ + /// + /// Represents a paged result for a model collection + /// + /// + [DataContract(Name = "pagedCollection", Namespace = "")] + public class PagedResult : PagedResult + { + public PagedResult(long totalItems, long pageNumber, long pageSize) + : base(totalItems, pageNumber, pageSize) + { } + + [DataMember(Name = "items")] + public IEnumerable Items { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/PropertyType.cs b/src/Umbraco.Core/Models/PropertyType.cs index 61736607c6..fd23756acb 100644 --- a/src/Umbraco.Core/Models/PropertyType.cs +++ b/src/Umbraco.Core/Models/PropertyType.cs @@ -26,8 +26,10 @@ namespace Umbraco.Core.Models private string _propertyEditorAlias; private ValueStorageType _valueStorageType; private bool _mandatory; + private string _mandatoryMessage; private int _sortOrder; private string _validationRegExp; + private string _validationRegExpMessage; private ContentVariation _variations; /// @@ -183,7 +185,7 @@ namespace Umbraco.Core.Models } /// - /// Gets of sets a value indicating whether a value for this property type is required. + /// Gets or sets a value indicating whether a value for this property type is required. /// [DataMember] public bool Mandatory @@ -192,6 +194,16 @@ namespace Umbraco.Core.Models set => SetPropertyValueAndDetectChanges(value, ref _mandatory, nameof(Mandatory)); } + /// + /// Gets or sets the custom validation message used when a value for this PropertyType is required + /// + [DataMember] + public string MandatoryMessage + { + get => _mandatoryMessage; + set => SetPropertyValueAndDetectChanges(value, ref _mandatoryMessage, nameof(MandatoryMessage)); + } + /// /// Gets of sets the sort order of the property type. /// @@ -212,6 +224,16 @@ namespace Umbraco.Core.Models set => SetPropertyValueAndDetectChanges(value, ref _validationRegExp, nameof(ValidationRegExp)); } + /// + /// Gets or sets the custom validation message used when a pattern for this PropertyType must be matched + /// + [DataMember] + public string ValidationRegExpMessage + { + get => _validationRegExpMessage; + set => SetPropertyValueAndDetectChanges(value, ref _validationRegExpMessage, nameof(ValidationRegExpMessage)); + } + /// /// Gets or sets the content variation of the property type. /// diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedContentTypeFactory.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedContentTypeFactory.cs index 816bfdbb01..89009ac7b8 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedContentTypeFactory.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedContentTypeFactory.cs @@ -30,6 +30,16 @@ /// Is used by constructor to create special property types. IPublishedPropertyType CreatePropertyType(IPublishedContentType contentType, string propertyTypeAlias, int dataTypeId, ContentVariation variations); + /// + /// Creates a core (non-user) published property type. + /// + /// The published content type owning the property. + /// The property type alias. + /// The datatype identifier. + /// The variations. + /// Is used by constructor to create special property types. + IPublishedPropertyType CreateCorePropertyType(IPublishedContentType contentType, string propertyTypeAlias, int dataTypeId, ContentVariation variations); + /// /// Gets a published datatype. /// diff --git a/src/Umbraco.Core/Models/PublishedContent/ModelType.cs b/src/Umbraco.Core/Models/PublishedContent/ModelType.cs index 540abda2c5..dd60eb9beb 100644 --- a/src/Umbraco.Core/Models/PublishedContent/ModelType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/ModelType.cs @@ -20,7 +20,9 @@ namespace Umbraco.Core.Models.PublishedContent { private ModelType(string contentTypeAlias) { - if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(contentTypeAlias)); + if (contentTypeAlias == null) throw new ArgumentNullException(nameof(contentTypeAlias)); + if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); + ContentTypeAlias = contentTypeAlias; Name = "{" + ContentTypeAlias + "}"; } @@ -75,7 +77,7 @@ namespace Umbraco.Core.Models.PublishedContent return type; var def = type.GetGenericTypeDefinition(); if (def == null) - throw new InvalidOperationException("panic"); + throw new PanicException($"The type {type} has not generic type definition"); var args = type.GetGenericArguments().Select(x => Map(x, modelTypes, true)).ToArray(); return def.MakeGenericType(args); @@ -114,7 +116,7 @@ namespace Umbraco.Core.Models.PublishedContent return type.FullName; var def = type.GetGenericTypeDefinition(); if (def == null) - throw new InvalidOperationException("panic"); + throw new PanicException($"The type {type} has not generic type definition"); var args = type.GetGenericArguments().Select(x => MapToName(x, map, true)).ToArray(); var defFullName = def.FullName.Substring(0, def.FullName.IndexOf('`')); diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentType.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentType.cs index 3b03cfc9ea..b11e991118 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentType.cs @@ -92,26 +92,26 @@ namespace Umbraco.Core.Models.PublishedContent { var aliases = new HashSet(propertyTypes.Select(x => x.Alias), StringComparer.OrdinalIgnoreCase); - foreach ((var alias, (var dataTypeId, var editorAlias)) in BuiltinMemberProperties) + foreach (var (alias, dataTypeId) in BuiltinMemberProperties) { if (aliases.Contains(alias)) continue; - propertyTypes.Add(factory.CreatePropertyType(this, alias, dataTypeId, ContentVariation.Nothing)); + propertyTypes.Add(factory.CreateCorePropertyType(this, alias, dataTypeId, ContentVariation.Nothing)); } } // TODO: this list somehow also exists in constants, see memberTypeRepository => remove duplicate! - private static readonly Dictionary BuiltinMemberProperties = new Dictionary + private static readonly Dictionary BuiltinMemberProperties = new Dictionary { - { "Email", (Constants.DataTypes.Textbox, Constants.PropertyEditors.Aliases.TextBox) }, - { "Username", (Constants.DataTypes.Textbox, Constants.PropertyEditors.Aliases.TextBox) }, - { "PasswordQuestion", (Constants.DataTypes.Textbox, Constants.PropertyEditors.Aliases.TextBox) }, - { "Comments", (Constants.DataTypes.Textbox, Constants.PropertyEditors.Aliases.TextBox) }, - { "IsApproved", (Constants.DataTypes.Boolean, Constants.PropertyEditors.Aliases.Boolean) }, - { "IsLockedOut", (Constants.DataTypes.Boolean, Constants.PropertyEditors.Aliases.Boolean) }, - { "LastLockoutDate", (Constants.DataTypes.DateTime, Constants.PropertyEditors.Aliases.DateTime) }, - { "CreateDate", (Constants.DataTypes.DateTime, Constants.PropertyEditors.Aliases.DateTime) }, - { "LastLoginDate", (Constants.DataTypes.DateTime, Constants.PropertyEditors.Aliases.DateTime) }, - { "LastPasswordChangeDate", (Constants.DataTypes.DateTime, Constants.PropertyEditors.Aliases.DateTime) }, + { "Email", Constants.DataTypes.Textbox }, + { "Username", Constants.DataTypes.Textbox }, + { "PasswordQuestion", Constants.DataTypes.Textbox }, + { "Comments", Constants.DataTypes.Textbox }, + { "IsApproved", Constants.DataTypes.Boolean }, + { "IsLockedOut", Constants.DataTypes.Boolean }, + { "LastLockoutDate", Constants.DataTypes.DateTime }, + { "CreateDate", Constants.DataTypes.DateTime }, + { "LastLoginDate", Constants.DataTypes.DateTime }, + { "LastPasswordChangeDate", Constants.DataTypes.DateTime }, }; #region Content type diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedContentTypeFactory.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedContentTypeFactory.cs index 17a15a2536..34094508c3 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedContentTypeFactory.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedContentTypeFactory.cs @@ -61,6 +61,12 @@ namespace Umbraco.Core.Models.PublishedContent return new PublishedPropertyType(contentType, propertyTypeAlias, dataTypeId, true, variations, _propertyValueConverters, _publishedModelFactory, this); } + /// + public IPublishedPropertyType CreateCorePropertyType(IPublishedContentType contentType, string propertyTypeAlias, int dataTypeId, ContentVariation variations = ContentVariation.Nothing) + { + return new PublishedPropertyType(contentType, propertyTypeAlias, dataTypeId, false, variations, _propertyValueConverters, _publishedModelFactory, this); + } + /// /// This method is for tests and is not intended to be used directly from application code. /// diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedCultureInfos.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedCultureInfos.cs index d5096158a7..908b97fc36 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedCultureInfos.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedCultureInfos.cs @@ -1,5 +1,4 @@ using System; -using Umbraco.Core.Exceptions; namespace Umbraco.Core.Models.PublishedContent { @@ -13,7 +12,8 @@ namespace Umbraco.Core.Models.PublishedContent /// public PublishedCultureInfo(string culture, string name, string urlSegment, DateTime date) { - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); Culture = culture ?? throw new ArgumentNullException(nameof(culture)); Name = name; diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedModelAttribute.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedModelAttribute.cs index 16eb614ee7..3f2c63a78f 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedModelAttribute.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedModelAttribute.cs @@ -1,5 +1,4 @@ using System; -using Umbraco.Core.Exceptions; namespace Umbraco.Core.Models.PublishedContent { @@ -19,7 +18,9 @@ namespace Umbraco.Core.Models.PublishedContent /// The content type alias. public PublishedModelAttribute(string contentTypeAlias) { - if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(contentTypeAlias)); + if (contentTypeAlias == null) throw new ArgumentNullException(nameof(contentTypeAlias)); + if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); + ContentTypeAlias = contentTypeAlias; } diff --git a/src/Umbraco.Core/Models/Relation.cs b/src/Umbraco.Core/Models/Relation.cs index f5d13c70c4..7afa476226 100644 --- a/src/Umbraco.Core/Models/Relation.cs +++ b/src/Umbraco.Core/Models/Relation.cs @@ -17,13 +17,36 @@ namespace Umbraco.Core.Models private IRelationType _relationType; private string _comment; + /// + /// Constructor for constructing the entity to be created + /// + /// + /// + /// public Relation(int parentId, int childId, IRelationType relationType) { _parentId = parentId; _childId = childId; _relationType = relationType; } - + + /// + /// Constructor for reconstructing the entity from the data source + /// + /// + /// + /// + /// + /// + public Relation(int parentId, int childId, Guid parentObjectType, Guid childObjectType, IRelationType relationType) + { + _parentId = parentId; + _childId = childId; + _relationType = relationType; + ParentObjectType = parentObjectType; + ChildObjectType = childObjectType; + } + /// /// Gets or sets the Parent Id of the Relation (Source) @@ -35,6 +58,9 @@ namespace Umbraco.Core.Models set => SetPropertyValueAndDetectChanges(value, ref _parentId, nameof(ParentId)); } + [DataMember] + public Guid ParentObjectType { get; set; } + /// /// Gets or sets the Child Id of the Relation (Destination) /// @@ -45,6 +71,9 @@ namespace Umbraco.Core.Models set => SetPropertyValueAndDetectChanges(value, ref _childId, nameof(ChildId)); } + [DataMember] + public Guid ChildObjectType { get; set; } + /// /// Gets or sets the for the Relation /// diff --git a/src/Umbraco.Core/Models/RelationType.cs b/src/Umbraco.Core/Models/RelationType.cs index 259b7bc4ef..1085ecdcdd 100644 --- a/src/Umbraco.Core/Models/RelationType.cs +++ b/src/Umbraco.Core/Models/RelationType.cs @@ -1,6 +1,5 @@ using System; using System.Runtime.Serialization; -using Umbraco.Core.Exceptions; using Umbraco.Core.Models.Entities; namespace Umbraco.Core.Models @@ -15,24 +14,26 @@ namespace Umbraco.Core.Models private string _name; private string _alias; private bool _isBidrectional; - private Guid _parentObjectType; - private Guid _childObjectType; + private Guid? _parentObjectType; + private Guid? _childObjectType; - public RelationType(Guid childObjectType, Guid parentObjectType, string alias) + //TODO: Should we put back the broken ctors with obsolete attributes? + + public RelationType(string alias, string name) + : this(name, alias, false, null, null) { - if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentNullOrEmptyException(nameof(alias)); - _childObjectType = childObjectType; - _parentObjectType = parentObjectType; + } + + public RelationType(string name, string alias, bool isBidrectional, Guid? parentObjectType, Guid? childObjectType) + { + _name = name; _alias = alias; - Name = _alias; + _isBidrectional = isBidrectional; + _parentObjectType = parentObjectType; + _childObjectType = childObjectType; } - public RelationType(Guid childObjectType, Guid parentObjectType, string alias, string name) - : this(childObjectType, parentObjectType, alias) - { - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); - Name = name; - } + /// /// Gets or sets the Name of the RelationType @@ -69,7 +70,7 @@ namespace Umbraco.Core.Models /// /// Corresponds to the NodeObjectType in the umbracoNode table [DataMember] - public Guid ParentObjectType + public Guid? ParentObjectType { get => _parentObjectType; set => SetPropertyValueAndDetectChanges(value, ref _parentObjectType, nameof(ParentObjectType)); @@ -80,7 +81,7 @@ namespace Umbraco.Core.Models /// /// Corresponds to the NodeObjectType in the umbracoNode table [DataMember] - public Guid ChildObjectType + public Guid? ChildObjectType { get => _childObjectType; set => SetPropertyValueAndDetectChanges(value, ref _childObjectType, nameof(ChildObjectType)); diff --git a/src/Umbraco.Core/Models/UserExtensions.cs b/src/Umbraco.Core/Models/UserExtensions.cs index cf7df4fb86..e00ac4ba15 100644 --- a/src/Umbraco.Core/Models/UserExtensions.cs +++ b/src/Umbraco.Core/Models/UserExtensions.cs @@ -67,7 +67,7 @@ namespace Umbraco.Core.Models if (user.Avatar.IsNullOrWhiteSpace()) { - var gravatarHash = user.Email.ToMd5(); + var gravatarHash = user.Email.GenerateHash(); var gravatarUrl = "https://www.gravatar.com/avatar/" + gravatarHash + "?d=404"; //try Gravatar diff --git a/src/Umbraco.Core/ObjectExtensions.cs b/src/Umbraco.Core/ObjectExtensions.cs index 68bc9c923d..78ad60f763 100644 --- a/src/Umbraco.Core/ObjectExtensions.cs +++ b/src/Umbraco.Core/ObjectExtensions.cs @@ -542,7 +542,7 @@ namespace Umbraco.Core { return "\"{0}\"".InvariantFormat(obj); } - if (obj is int || obj is Int16 || obj is Int64 || obj is float || obj is double || obj is bool || obj is int? || obj is Int16? || obj is Int64? || obj is float? || obj is double? || obj is bool?) + if (obj is int || obj is short || obj is long || obj is float || obj is double || obj is bool || obj is int? || obj is float? || obj is double? || obj is bool?) { return "{0}".InvariantFormat(obj); } @@ -723,7 +723,7 @@ namespace Umbraco.Core { return typeConverter; } - + var converter = TypeDescriptor.GetConverter(target); if (converter.CanConvertFrom(source)) { @@ -788,6 +788,6 @@ namespace Umbraco.Core return BoolConvertCache[type] = false; } - + } } diff --git a/src/Umbraco.Core/Packaging/PackageDataInstallation.cs b/src/Umbraco.Core/Packaging/PackageDataInstallation.cs index c811f484bc..6a5acb0dc7 100644 --- a/src/Umbraco.Core/Packaging/PackageDataInstallation.cs +++ b/src/Umbraco.Core/Packaging/PackageDataInstallation.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Text.RegularExpressions; using System.Web; using System.Xml.Linq; using System.Xml.XPath; @@ -12,7 +11,9 @@ using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; using Umbraco.Core.Models.Packaging; +using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Scoping; using Umbraco.Core.Services; using Umbraco.Core.Services.Implement; @@ -26,13 +27,14 @@ namespace Umbraco.Core.Packaging private readonly ILocalizationService _localizationService; private readonly IDataTypeService _dataTypeService; private readonly PropertyEditorCollection _propertyEditors; + private readonly IScopeProvider _scopeProvider; private readonly IEntityService _entityService; private readonly IContentTypeService _contentTypeService; private readonly IContentService _contentService; public PackageDataInstallation(ILogger logger, IFileService fileService, IMacroService macroService, ILocalizationService localizationService, IDataTypeService dataTypeService, IEntityService entityService, IContentTypeService contentTypeService, - IContentService contentService, PropertyEditorCollection propertyEditors) + IContentService contentService, PropertyEditorCollection propertyEditors, IScopeProvider scopeProvider) { _logger = logger; _fileService = fileService; @@ -40,12 +42,13 @@ namespace Umbraco.Core.Packaging _localizationService = localizationService; _dataTypeService = dataTypeService; _propertyEditors = propertyEditors; + _scopeProvider = scopeProvider; _entityService = entityService; _contentTypeService = contentTypeService; _contentService = contentService; } - #region Uninstall + #region Install/Uninstall public UninstallationSummary UninstallPackageData(PackageDefinition package, int userId) { @@ -58,93 +61,97 @@ namespace Umbraco.Core.Packaging var removedDataTypes = new List(); var removedLanguages = new List(); - - //Uninstall templates - foreach (var item in package.Templates.ToArray()) + using (var scope = _scopeProvider.CreateScope()) { - if (int.TryParse(item, out var nId) == false) continue; - var found = _fileService.GetTemplate(nId); - if (found != null) + //Uninstall templates + foreach (var item in package.Templates.ToArray()) { - removedTemplates.Add(found); - _fileService.DeleteTemplate(found.Alias, userId); + if (int.TryParse(item, out var nId) == false) continue; + var found = _fileService.GetTemplate(nId); + if (found != null) + { + removedTemplates.Add(found); + _fileService.DeleteTemplate(found.Alias, userId); + } + package.Templates.Remove(nId.ToString()); } - package.Templates.Remove(nId.ToString()); - } - //Uninstall macros - foreach (var item in package.Macros.ToArray()) - { - if (int.TryParse(item, out var nId) == false) continue; - var macro = _macroService.GetById(nId); - if (macro != null) + //Uninstall macros + foreach (var item in package.Macros.ToArray()) { - removedMacros.Add(macro); - _macroService.Delete(macro, userId); + if (int.TryParse(item, out var nId) == false) continue; + var macro = _macroService.GetById(nId); + if (macro != null) + { + removedMacros.Add(macro); + _macroService.Delete(macro, userId); + } + package.Macros.Remove(nId.ToString()); } - package.Macros.Remove(nId.ToString()); - } - //Remove Document Types - var contentTypes = new List(); - var contentTypeService = _contentTypeService; - foreach (var item in package.DocumentTypes.ToArray()) - { - if (int.TryParse(item, out var nId) == false) continue; - var contentType = contentTypeService.Get(nId); - if (contentType == null) continue; - contentTypes.Add(contentType); - package.DocumentTypes.Remove(nId.ToString(CultureInfo.InvariantCulture)); - } - - //Order the DocumentTypes before removing them - if (contentTypes.Any()) - { - // TODO: I don't think this ordering is necessary - var orderedTypes = (from contentType in contentTypes - orderby contentType.ParentId descending, contentType.Id descending - select contentType).ToList(); - removedContentTypes.AddRange(orderedTypes); - contentTypeService.Delete(orderedTypes, userId); - } - - //Remove Dictionary items - foreach (var item in package.DictionaryItems.ToArray()) - { - if (int.TryParse(item, out var nId) == false) continue; - var di = _localizationService.GetDictionaryItemById(nId); - if (di != null) + //Remove Document Types + var contentTypes = new List(); + var contentTypeService = _contentTypeService; + foreach (var item in package.DocumentTypes.ToArray()) { - removedDictionaryItems.Add(di); - _localizationService.Delete(di, userId); + if (int.TryParse(item, out var nId) == false) continue; + var contentType = contentTypeService.Get(nId); + if (contentType == null) continue; + contentTypes.Add(contentType); + package.DocumentTypes.Remove(nId.ToString(CultureInfo.InvariantCulture)); } - package.DictionaryItems.Remove(nId.ToString()); - } - //Remove Data types - foreach (var item in package.DataTypes.ToArray()) - { - if (int.TryParse(item, out var nId) == false) continue; - var dtd = _dataTypeService.GetDataType(nId); - if (dtd != null) + //Order the DocumentTypes before removing them + if (contentTypes.Any()) { - removedDataTypes.Add(dtd); - _dataTypeService.Delete(dtd, userId); + // TODO: I don't think this ordering is necessary + var orderedTypes = (from contentType in contentTypes + orderby contentType.ParentId descending, contentType.Id descending + select contentType).ToList(); + removedContentTypes.AddRange(orderedTypes); + contentTypeService.Delete(orderedTypes, userId); } - package.DataTypes.Remove(nId.ToString()); - } - //Remove Langs - foreach (var item in package.Languages.ToArray()) - { - if (int.TryParse(item, out var nId) == false) continue; - var lang = _localizationService.GetLanguageById(nId); - if (lang != null) + //Remove Dictionary items + foreach (var item in package.DictionaryItems.ToArray()) { - removedLanguages.Add(lang); - _localizationService.Delete(lang, userId); + if (int.TryParse(item, out var nId) == false) continue; + var di = _localizationService.GetDictionaryItemById(nId); + if (di != null) + { + removedDictionaryItems.Add(di); + _localizationService.Delete(di, userId); + } + package.DictionaryItems.Remove(nId.ToString()); } - package.Languages.Remove(nId.ToString()); + + //Remove Data types + foreach (var item in package.DataTypes.ToArray()) + { + if (int.TryParse(item, out var nId) == false) continue; + var dtd = _dataTypeService.GetDataType(nId); + if (dtd != null) + { + removedDataTypes.Add(dtd); + _dataTypeService.Delete(dtd, userId); + } + package.DataTypes.Remove(nId.ToString()); + } + + //Remove Langs + foreach (var item in package.Languages.ToArray()) + { + if (int.TryParse(item, out var nId) == false) continue; + var lang = _localizationService.GetLanguageById(nId); + if (lang != null) + { + removedLanguages.Add(lang); + _localizationService.Delete(lang, userId); + } + package.Languages.Remove(nId.ToString()); + } + + scope.Complete(); } // create a summary of what was actually removed, for PackagingService.UninstalledPackage @@ -165,14 +172,40 @@ namespace Umbraco.Core.Packaging } + public InstallationSummary InstallPackageData(CompiledPackage compiledPackage, int userId) + { + using (var scope = _scopeProvider.CreateScope()) + { + var installationSummary = new InstallationSummary + { + DataTypesInstalled = ImportDataTypes(compiledPackage.DataTypes.ToList(), userId), + LanguagesInstalled = ImportLanguages(compiledPackage.Languages, userId), + DictionaryItemsInstalled = ImportDictionaryItems(compiledPackage.DictionaryItems, userId), + MacrosInstalled = ImportMacros(compiledPackage.Macros, userId), + TemplatesInstalled = ImportTemplates(compiledPackage.Templates.ToList(), userId), + DocumentTypesInstalled = ImportDocumentTypes(compiledPackage.DocumentTypes, userId) + }; + + //we need a reference to the imported doc types to continue + var importedDocTypes = installationSummary.DocumentTypesInstalled.ToDictionary(x => x.Alias, x => x); + + installationSummary.StylesheetsInstalled = ImportStylesheets(compiledPackage.Stylesheets, userId); + installationSummary.ContentInstalled = ImportContent(compiledPackage.Documents, importedDocTypes, userId); + + scope.Complete(); + + return installationSummary; + } + } + #endregion #region Content - public IEnumerable ImportContent(IEnumerable docs, IDictionary importedDocumentTypes, int userId) + public IReadOnlyList ImportContent(IEnumerable docs, IDictionary importedDocumentTypes, int userId) { - return docs.SelectMany(x => ImportContent(x, -1, importedDocumentTypes, userId)); + return docs.SelectMany(x => ImportContent(x, -1, importedDocumentTypes, userId)).ToList(); } /// @@ -353,7 +386,7 @@ namespace Umbraco.Core.Packaging #region DocumentTypes - public IEnumerable ImportDocumentType(XElement docTypeElement, int userId) + public IReadOnlyList ImportDocumentType(XElement docTypeElement, int userId) { return ImportDocumentTypes(new[] { docTypeElement }, userId); } @@ -364,7 +397,7 @@ namespace Umbraco.Core.Packaging /// Xml to import /// Optional id of the User performing the operation. Default is zero (admin). /// An enumerable list of generated ContentTypes - public IEnumerable ImportDocumentTypes(IEnumerable docTypeElements, int userId) + public IReadOnlyList ImportDocumentTypes(IEnumerable docTypeElements, int userId) { return ImportDocumentTypes(docTypeElements.ToList(), true, userId); } @@ -376,7 +409,7 @@ namespace Umbraco.Core.Packaging /// Boolean indicating whether or not to import the /// Optional id of the User performing the operation. Default is zero (admin). /// An enumerable list of generated ContentTypes - public IEnumerable ImportDocumentTypes(IReadOnlyCollection unsortedDocumentTypes, bool importStructure, int userId) + public IReadOnlyList ImportDocumentTypes(IReadOnlyCollection unsortedDocumentTypes, bool importStructure, int userId) { var importedContentTypes = new Dictionary(); @@ -575,12 +608,11 @@ namespace Umbraco.Core.Packaging contentType.Thumbnail = infoElement.Element("Thumbnail").Value; contentType.Description = infoElement.Element("Description").Value; - //NOTE AllowAtRoot is a new property in the package xml so we need to verify it exists before using it. + //NOTE AllowAtRoot, IsListView, IsElement and Variations are new properties in the package xml so we need to verify it exists before using it. var allowAtRoot = infoElement.Element("AllowAtRoot"); if (allowAtRoot != null) contentType.AllowedAsRoot = allowAtRoot.Value.InvariantEquals("true"); - //NOTE IsListView is a new property in the package xml so we need to verify it exists before using it. var isListView = infoElement.Element("IsListView"); if (isListView != null) contentType.IsContainer = isListView.Value.InvariantEquals("true"); @@ -589,6 +621,10 @@ namespace Umbraco.Core.Packaging if (isElement != null) contentType.IsElement = isElement.Value.InvariantEquals("true"); + var variationsElement = infoElement.Element("Variations"); + if (variationsElement != null) + contentType.Variations = (ContentVariation)Enum.Parse(typeof(ContentVariation), variationsElement.Value); + //Name of the master corresponds to the parent and we need to ensure that the Parent Id is set var masterElement = infoElement.Element("Master"); if (masterElement != null) @@ -614,7 +650,7 @@ namespace Umbraco.Core.Packaging var compositionContentType = importedContentTypes.ContainsKey(compositionAlias) ? importedContentTypes[compositionAlias] : _contentTypeService.Get(compositionAlias); - var added = contentType.AddContentType(compositionContentType); + contentType.AddContentType(compositionContentType); } } } @@ -748,9 +784,21 @@ namespace Umbraco.Core.Packaging { Name = property.Element("Name").Value, Description = (string)property.Element("Description"), - Mandatory = property.Element("Mandatory") != null ? property.Element("Mandatory").Value.ToLowerInvariant().Equals("true") : false, + Mandatory = property.Element("Mandatory") != null + ? property.Element("Mandatory").Value.ToLowerInvariant().Equals("true") + : false, + MandatoryMessage = property.Element("MandatoryMessage") != null + ? (string)property.Element("MandatoryMessage") + : string.Empty, + ValidationRegExp = (string)property.Element("Validation"), - SortOrder = sortOrder + ValidationRegExpMessage = property.Element("ValidationRegExpMessage") != null + ? (string)property.Element("ValidationRegExpMessage") + : string.Empty, + SortOrder = sortOrder, + Variations = property.Element("Variations") != null + ? (ContentVariation)Enum.Parse(typeof(ContentVariation), property.Element("Variations").Value) + : ContentVariation.Nothing }; var tab = (string)property.Element("Tab"); @@ -817,7 +865,7 @@ namespace Umbraco.Core.Packaging /// Xml to import /// Optional id of the user /// An enumerable list of generated DataTypeDefinitions - public IEnumerable ImportDataTypes(IReadOnlyCollection dataTypeElements, int userId) + public IReadOnlyList ImportDataTypes(IReadOnlyCollection dataTypeElements, int userId) { var dataTypes = new List(); @@ -946,13 +994,13 @@ namespace Umbraco.Core.Packaging /// Xml to import /// /// An enumerable list of dictionary items - public IEnumerable ImportDictionaryItems(IEnumerable dictionaryItemElementList, int userId) + public IReadOnlyList ImportDictionaryItems(IEnumerable dictionaryItemElementList, int userId) { var languages = _localizationService.GetAllLanguages().ToList(); return ImportDictionaryItems(dictionaryItemElementList, languages, null, userId); } - private IEnumerable ImportDictionaryItems(IEnumerable dictionaryItemElementList, List languages, Guid? parentId, int userId) + private IReadOnlyList ImportDictionaryItems(IEnumerable dictionaryItemElementList, List languages, Guid? parentId, int userId) { var items = new List(); foreach (var dictionaryItemElement in dictionaryItemElementList) @@ -1029,7 +1077,7 @@ namespace Umbraco.Core.Packaging /// Xml to import /// Optional id of the User performing the operation /// An enumerable list of generated languages - public IEnumerable ImportLanguages(IEnumerable languageElements, int userId) + public IReadOnlyList ImportLanguages(IEnumerable languageElements, int userId) { var list = new List(); foreach (var languageElement in languageElements) @@ -1058,7 +1106,7 @@ namespace Umbraco.Core.Packaging /// Xml to import /// Optional id of the User performing the operation /// - public IEnumerable ImportMacros(IEnumerable macroElements, int userId) + public IReadOnlyList ImportMacros(IEnumerable macroElements, int userId) { var macros = macroElements.Select(ParseMacroElement).ToList(); @@ -1148,7 +1196,7 @@ namespace Umbraco.Core.Packaging #region Stylesheets - public IEnumerable ImportStylesheets(IEnumerable stylesheetElements, int userId) + public IReadOnlyList ImportStylesheets(IEnumerable stylesheetElements, int userId) { var result = new List(); @@ -1216,7 +1264,7 @@ namespace Umbraco.Core.Packaging /// Xml to import /// Optional user id /// An enumerable list of generated Templates - public IEnumerable ImportTemplates(IReadOnlyCollection templateElements, int userId) + public IReadOnlyList ImportTemplates(IReadOnlyCollection templateElements, int userId) { var templates = new List(); diff --git a/src/Umbraco.Core/Packaging/PackageInstallation.cs b/src/Umbraco.Core/Packaging/PackageInstallation.cs index d791295b38..a42ee1aeb2 100644 --- a/src/Umbraco.Core/Packaging/PackageInstallation.cs +++ b/src/Umbraco.Core/Packaging/PackageInstallation.cs @@ -90,21 +90,8 @@ namespace Umbraco.Core.Packaging public InstallationSummary InstallPackageData(PackageDefinition packageDefinition, CompiledPackage compiledPackage, int userId) { - var installationSummary = new InstallationSummary - { - DataTypesInstalled = _packageDataInstallation.ImportDataTypes(compiledPackage.DataTypes.ToList(), userId), - LanguagesInstalled = _packageDataInstallation.ImportLanguages(compiledPackage.Languages, userId), - DictionaryItemsInstalled = _packageDataInstallation.ImportDictionaryItems(compiledPackage.DictionaryItems, userId), - MacrosInstalled = _packageDataInstallation.ImportMacros(compiledPackage.Macros, userId), - TemplatesInstalled = _packageDataInstallation.ImportTemplates(compiledPackage.Templates.ToList(), userId), - DocumentTypesInstalled = _packageDataInstallation.ImportDocumentTypes(compiledPackage.DocumentTypes, userId) - }; + var installationSummary = _packageDataInstallation.InstallPackageData(compiledPackage, userId); - //we need a reference to the imported doc types to continue - var importedDocTypes = installationSummary.DocumentTypesInstalled.ToDictionary(x => x.Alias, x => x); - - installationSummary.StylesheetsInstalled = _packageDataInstallation.ImportStylesheets(compiledPackage.Stylesheets, userId); - installationSummary.ContentInstalled = _packageDataInstallation.ImportContent(compiledPackage.Documents, importedDocTypes, userId); installationSummary.Actions = CompiledPackageXmlParser.GetPackageActions(XElement.Parse(compiledPackage.Actions), compiledPackage.Name); installationSummary.MetaData = compiledPackage; installationSummary.FilesInstalled = packageDefinition.Files; diff --git a/src/Umbraco.Core/Persistence/BulkDataReader.cs b/src/Umbraco.Core/Persistence/BulkDataReader.cs index 1eaa88ee88..7dbe74922a 100644 --- a/src/Umbraco.Core/Persistence/BulkDataReader.cs +++ b/src/Umbraco.Core/Persistence/BulkDataReader.cs @@ -470,7 +470,7 @@ namespace Umbraco.Core.Persistence break; case SqlDbType.SmallInt: - dataType = typeof(Int16); + dataType = typeof(short); dataTypeName = "smallint"; break; @@ -688,34 +688,34 @@ namespace Umbraco.Core.Persistence DataColumnCollection columns = _schemaTable.Columns; - columns.Add(SchemaTableColumn.ColumnName, typeof(System.String)); - columns.Add(SchemaTableColumn.ColumnOrdinal, typeof(System.Int32)); - columns.Add(SchemaTableColumn.ColumnSize, typeof(System.Int32)); - columns.Add(SchemaTableColumn.NumericPrecision, typeof(System.Int16)); - columns.Add(SchemaTableColumn.NumericScale, typeof(System.Int16)); - columns.Add(SchemaTableColumn.IsUnique, typeof(System.Boolean)); - columns.Add(SchemaTableColumn.IsKey, typeof(System.Boolean)); - columns.Add(SchemaTableOptionalColumn.BaseServerName, typeof(System.String)); - columns.Add(SchemaTableOptionalColumn.BaseCatalogName, typeof(System.String)); - columns.Add(SchemaTableColumn.BaseColumnName, typeof(System.String)); - columns.Add(SchemaTableColumn.BaseSchemaName, typeof(System.String)); - columns.Add(SchemaTableColumn.BaseTableName, typeof(System.String)); - columns.Add(SchemaTableColumn.DataType, typeof(System.Type)); - columns.Add(SchemaTableColumn.AllowDBNull, typeof(System.Boolean)); - columns.Add(SchemaTableColumn.ProviderType, typeof(System.Int32)); - columns.Add(SchemaTableColumn.IsAliased, typeof(System.Boolean)); - columns.Add(SchemaTableColumn.IsExpression, typeof(System.Boolean)); - columns.Add(BulkDataReader.IsIdentitySchemaColumn, typeof(System.Boolean)); - columns.Add(SchemaTableOptionalColumn.IsAutoIncrement, typeof(System.Boolean)); - columns.Add(SchemaTableOptionalColumn.IsRowVersion, typeof(System.Boolean)); - columns.Add(SchemaTableOptionalColumn.IsHidden, typeof(System.Boolean)); - columns.Add(SchemaTableColumn.IsLong, typeof(System.Boolean)); - columns.Add(SchemaTableOptionalColumn.IsReadOnly, typeof(System.Boolean)); - columns.Add(SchemaTableOptionalColumn.ProviderSpecificDataType, typeof(System.Type)); - columns.Add(BulkDataReader.DataTypeNameSchemaColumn, typeof(System.String)); - columns.Add(BulkDataReader.XmlSchemaCollectionDatabaseSchemaColumn, typeof(System.String)); - columns.Add(BulkDataReader.XmlSchemaCollectionOwningSchemaSchemaColumn, typeof(System.String)); - columns.Add(BulkDataReader.XmlSchemaCollectionNameSchemaColumn, typeof(System.String)); + columns.Add(SchemaTableColumn.ColumnName, typeof(string)); + columns.Add(SchemaTableColumn.ColumnOrdinal, typeof(int)); + columns.Add(SchemaTableColumn.ColumnSize, typeof(int)); + columns.Add(SchemaTableColumn.NumericPrecision, typeof(short)); + columns.Add(SchemaTableColumn.NumericScale, typeof(short)); + columns.Add(SchemaTableColumn.IsUnique, typeof(bool)); + columns.Add(SchemaTableColumn.IsKey, typeof(bool)); + columns.Add(SchemaTableOptionalColumn.BaseServerName, typeof(string)); + columns.Add(SchemaTableOptionalColumn.BaseCatalogName, typeof(string)); + columns.Add(SchemaTableColumn.BaseColumnName, typeof(string)); + columns.Add(SchemaTableColumn.BaseSchemaName, typeof(string)); + columns.Add(SchemaTableColumn.BaseTableName, typeof(string)); + columns.Add(SchemaTableColumn.DataType, typeof(Type)); + columns.Add(SchemaTableColumn.AllowDBNull, typeof(bool)); + columns.Add(SchemaTableColumn.ProviderType, typeof(int)); + columns.Add(SchemaTableColumn.IsAliased, typeof(bool)); + columns.Add(SchemaTableColumn.IsExpression, typeof(bool)); + columns.Add(BulkDataReader.IsIdentitySchemaColumn, typeof(bool)); + columns.Add(SchemaTableOptionalColumn.IsAutoIncrement, typeof(bool)); + columns.Add(SchemaTableOptionalColumn.IsRowVersion, typeof(bool)); + columns.Add(SchemaTableOptionalColumn.IsHidden, typeof(bool)); + columns.Add(SchemaTableColumn.IsLong, typeof(bool)); + columns.Add(SchemaTableOptionalColumn.IsReadOnly, typeof(bool)); + columns.Add(SchemaTableOptionalColumn.ProviderSpecificDataType, typeof(Type)); + columns.Add(BulkDataReader.DataTypeNameSchemaColumn, typeof(string)); + columns.Add(BulkDataReader.XmlSchemaCollectionDatabaseSchemaColumn, typeof(string)); + columns.Add(BulkDataReader.XmlSchemaCollectionOwningSchemaSchemaColumn, typeof(string)); + columns.Add(BulkDataReader.XmlSchemaCollectionNameSchemaColumn, typeof(string)); } #endregion @@ -1090,7 +1090,7 @@ namespace Umbraco.Core.Persistence /// public decimal GetDecimal(int i) { - return (Decimal)GetValue(i); + return (decimal)GetValue(i); } /// diff --git a/src/Umbraco.Core/Persistence/Constants-Locks.cs b/src/Umbraco.Core/Persistence/Constants-Locks.cs index 1dcd2408e7..e64f40ced7 100644 --- a/src/Umbraco.Core/Persistence/Constants-Locks.cs +++ b/src/Umbraco.Core/Persistence/Constants-Locks.cs @@ -8,6 +8,11 @@ namespace Umbraco.Core /// public static class Locks { + /// + /// The lock + /// + public const int MainDom = -1000; + /// /// All servers. /// diff --git a/src/Umbraco.Core/Persistence/DatabasenodeLockExtensions.cs b/src/Umbraco.Core/Persistence/DatabasenodeLockExtensions.cs deleted file mode 100644 index 48edee3c94..0000000000 --- a/src/Umbraco.Core/Persistence/DatabasenodeLockExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Runtime.CompilerServices; - -namespace Umbraco.Core.Persistence -{ - internal static class DatabaseNodeLockExtensions - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void ValidateDatabase(IUmbracoDatabase database) - { - if (database == null) - throw new ArgumentNullException("database"); - if (database.GetCurrentTransactionIsolationLevel() < IsolationLevel.RepeatableRead) - throw new InvalidOperationException("A transaction with minimum RepeatableRead isolation level is required."); - } - - // updating a record within a repeatable-read transaction gets an exclusive lock on - // that record which will be kept until the transaction is ended, effectively locking - // out all other accesses to that record - thus obtaining an exclusive lock over the - // protected resources. - public static void AcquireLockNodeWriteLock(this IUmbracoDatabase database, int nodeId) - { - ValidateDatabase(database); - - database.Execute("UPDATE umbracoLock SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id", - new { @id = nodeId }); - } - - // reading a record within a repeatable-read transaction gets a shared lock on - // that record which will be kept until the transaction is ended, effectively preventing - // other write accesses to that record - thus obtaining a shared lock over the protected - // resources. - public static void AcquireLockNodeReadLock(this IUmbracoDatabase database, int nodeId) - { - ValidateDatabase(database); - - database.ExecuteScalar("SELECT value FROM umbracoLock WHERE id=@id", - new { @id = nodeId }); - } - } -} diff --git a/src/Umbraco.Core/Persistence/Dtos/ContentVersionDto.cs b/src/Umbraco.Core/Persistence/Dtos/ContentVersionDto.cs index 3c2c3deda4..4b203c128f 100644 --- a/src/Umbraco.Core/Persistence/Dtos/ContentVersionDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/ContentVersionDto.cs @@ -19,6 +19,7 @@ namespace Umbraco.Core.Persistence.Dtos [Column("nodeId")] [ForeignKey(typeof(ContentDto))] + [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_NodeId", ForColumns = "nodeId,current")] public int NodeId { get; set; } [Column("versionDate")] // TODO: db rename to 'updateDate' @@ -30,7 +31,6 @@ namespace Umbraco.Core.Persistence.Dtos [NullSetting(NullSetting = NullSettings.Null)] public int? UserId { get => _userId == 0 ? null : _userId; set => _userId = value; } //return null if zero - // TODO: we need an index on this it is used almost always in querying and sorting [Column("current")] public bool Current { get; set; } diff --git a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs index 8c52aa1e15..3e8d6e7496 100644 --- a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeDto.cs @@ -43,10 +43,20 @@ namespace Umbraco.Core.Persistence.Dtos [Constraint(Default = "0")] public bool Mandatory { get; set; } + [Column("mandatoryMessage")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(500)] + public string MandatoryMessage { get; set; } + [Column("validationRegExp")] [NullSetting(NullSetting = NullSettings.Null)] public string ValidationRegExp { get; set; } + [Column("validationRegExpMessage")] + [NullSetting(NullSetting = NullSettings.Null)] + [Length(500)] + public string ValidationRegExpMessage { get; set; } + [Column("Description")] [NullSetting(NullSetting = NullSettings.Null)] [Length(2000)] diff --git a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs index c68dee42b5..4c352a0134 100644 --- a/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/PropertyTypeReadOnlyDto.cs @@ -32,9 +32,15 @@ namespace Umbraco.Core.Persistence.Dtos [Column("mandatory")] public bool Mandatory { get; set; } + [Column("mandatoryMessage")] + public string MandatoryMessage { get; set; } + [Column("validationRegExp")] public string ValidationRegExp { get; set; } + [Column("validationRegExpMessage")] + public string ValidationRegExpMessage { get; set; } + [Column("Description")] public string Description { get; set; } diff --git a/src/Umbraco.Core/Persistence/Dtos/RelationDto.cs b/src/Umbraco.Core/Persistence/Dtos/RelationDto.cs index f1fd3007d7..b21866eb8b 100644 --- a/src/Umbraco.Core/Persistence/Dtos/RelationDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/RelationDto.cs @@ -34,5 +34,13 @@ namespace Umbraco.Core.Persistence.Dtos [Column("comment")] [Length(1000)] public string Comment { get; set; } + + [ResultColumn] + [Column("parentObjectType")] + public Guid ParentObjectType { get; set; } + + [ResultColumn] + [Column("childObjectType")] + public Guid ChildObjectType { get; set; } } } diff --git a/src/Umbraco.Core/Persistence/Dtos/RelationTypeDto.cs b/src/Umbraco.Core/Persistence/Dtos/RelationTypeDto.cs index e972192844..d3e107d23f 100644 --- a/src/Umbraco.Core/Persistence/Dtos/RelationTypeDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/RelationTypeDto.cs @@ -9,7 +9,7 @@ namespace Umbraco.Core.Persistence.Dtos [ExplicitColumns] internal class RelationTypeDto { - public const int NodeIdSeed = 4; + public const int NodeIdSeed = 10; [Column("id")] [PrimaryKeyColumn(IdentitySeed = NodeIdSeed)] @@ -23,17 +23,20 @@ namespace Umbraco.Core.Persistence.Dtos public bool Dual { get; set; } [Column("parentObjectType")] - public Guid ParentObjectType { get; set; } + [NullSetting(NullSetting = NullSettings.Null)] + public Guid? ParentObjectType { get; set; } [Column("childObjectType")] - public Guid ChildObjectType { get; set; } + [NullSetting(NullSetting = NullSettings.Null)] + public Guid? ChildObjectType { get; set; } [Column("name")] + [NullSetting(NullSetting = NullSettings.NotNull)] [Index(IndexTypes.UniqueNonClustered, Name = "IX_umbracoRelationType_name")] public string Name { get; set; } [Column("alias")] - [NullSetting(NullSetting = NullSettings.Null)] + [NullSetting(NullSetting = NullSettings.NotNull)] [Length(100)] [Index(IndexTypes.UniqueNonClustered, Name = "IX_umbracoRelationType_alias")] public string Alias { get; set; } diff --git a/src/Umbraco.Core/Persistence/EntityNotFoundException.cs b/src/Umbraco.Core/Persistence/EntityNotFoundException.cs index e0fe778fa6..ea6d5142f0 100644 --- a/src/Umbraco.Core/Persistence/EntityNotFoundException.cs +++ b/src/Umbraco.Core/Persistence/EntityNotFoundException.cs @@ -1,38 +1,96 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Runtime.Serialization; namespace Umbraco.Core.Persistence { - - // TODO: Would be good to use this exception type anytime we cannot find an entity - /// - /// An exception used to indicate that an umbraco entity could not be found + /// An exception used to indicate that an Umbraco entity could not be found. /// + /// + [Obsolete("Instead of throwing an exception, return null or an HTTP 404 status code instead.")] + [Serializable] public class EntityNotFoundException : Exception { + /// + /// Gets the identifier. + /// + /// + /// The identifier. + /// + /// + /// This object should be serializable to prevent a to be thrown. + /// public object Id { get; private set; } - private readonly string _msg; - public EntityNotFoundException(object id, string msg) + /// + /// Initializes a new instance of the class. + /// + public EntityNotFoundException() + { } + + /// + /// Initializes a new instance of the class. + /// + /// The identifier. + /// The message. + public EntityNotFoundException(object id, string message) + : base(message) { Id = id; - _msg = msg; } - public EntityNotFoundException(string msg) + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public EntityNotFoundException(string message) + : base(message) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public EntityNotFoundException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected EntityNotFoundException(SerializationInfo info, StreamingContext context) + : base(info, context) { - _msg = msg; + Id = info.GetValue(nameof(Id), typeof(object)); } - public override string Message + /// + /// When overridden in a derived class, sets the with information about the exception. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + /// info + public override void GetObjectData(SerializationInfo info, StreamingContext context) { - get { return _msg; } + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + + info.AddValue(nameof(Id), Id); + + base.GetObjectData(info, context); } + /// + /// Returns a that represents this instance. + /// + /// + /// A that represents this instance. + /// public override string ToString() { var result = base.ToString(); diff --git a/src/Umbraco.Core/Persistence/Factories/ContentBaseFactory.cs b/src/Umbraco.Core/Persistence/Factories/ContentBaseFactory.cs index 434e0393cd..2b2bed1d9e 100644 --- a/src/Umbraco.Core/Persistence/Factories/ContentBaseFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/ContentBaseFactory.cs @@ -1,17 +1,15 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.RegularExpressions; using Umbraco.Core.Models; using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Persistence.Repositories; +using Umbraco.Core.PropertyEditors; namespace Umbraco.Core.Persistence.Factories { internal class ContentBaseFactory { - private static readonly Regex MediaPathPattern = new Regex(@"(/media/.+?)(?:['""]|$)", RegexOptions.Compiled); - /// /// Builds an IContent item from a dto and content type. /// @@ -189,7 +187,7 @@ namespace Umbraco.Core.Persistence.Factories /// /// Builds a dto from an IMedia item. /// - public static MediaDto BuildDto(IMedia entity) + public static MediaDto BuildDto(PropertyEditorCollection propertyEditors, IMedia entity) { var contentDto = BuildContentDto(entity, Constants.ObjectTypes.Media); @@ -197,7 +195,7 @@ namespace Umbraco.Core.Persistence.Factories { NodeId = entity.Id, ContentDto = contentDto, - MediaVersionDto = BuildMediaVersionDto(entity, contentDto) + MediaVersionDto = BuildMediaVersionDto(propertyEditors, entity, contentDto) }; return dto; @@ -291,12 +289,20 @@ namespace Umbraco.Core.Persistence.Factories return dto; } - private static MediaVersionDto BuildMediaVersionDto(IMedia entity, ContentDto contentDto) + private static MediaVersionDto BuildMediaVersionDto(PropertyEditorCollection propertyEditors, IMedia entity, ContentDto contentDto) { // try to get a path from the string being stored for media // TODO: only considering umbracoFile - TryMatch(entity.GetValue("umbracoFile"), out var path); + string path = null; + + if (entity.Properties.TryGetValue(Constants.Conventions.Media.File, out var property) + && propertyEditors.TryGet(property.PropertyType.PropertyEditorAlias, out var editor) + && editor is IDataEditorWithMediaPath dataEditor) + { + var value = property.GetValue(); + path = dataEditor.GetMediaPath(value); + } var dto = new MediaVersionDto { @@ -308,22 +314,5 @@ namespace Umbraco.Core.Persistence.Factories return dto; } - - // TODO: this should NOT be here?! - // more dark magic ;-( - internal static bool TryMatch(string text, out string path) - { - // In v8 we should allow exposing this via the property editor in a much nicer way so that the property editor - // can tell us directly what any URL is for a given property if it contains an asset - - path = null; - if (string.IsNullOrWhiteSpace(text)) return false; - - var m = MediaPathPattern.Match(text); - if (!m.Success || m.Groups.Count != 2) return false; - - path = m.Groups[1].Value; - return true; - } } } diff --git a/src/Umbraco.Core/Persistence/Factories/PropertyFactory.cs b/src/Umbraco.Core/Persistence/Factories/PropertyFactory.cs index f1473b5888..33dabe1b24 100644 --- a/src/Umbraco.Core/Persistence/Factories/PropertyFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/PropertyFactory.cs @@ -12,7 +12,7 @@ namespace Umbraco.Core.Persistence.Factories public static IEnumerable BuildEntities(PropertyType[] propertyTypes, IReadOnlyCollection dtos, int publishedVersionId, ILanguageRepository languageRepository) { var properties = new List(); - var xdtos = dtos.GroupBy(x => x.PropertyTypeId).ToDictionary(x => x.Key, x => (IEnumerable) x); + var xdtos = dtos.GroupBy(x => x.PropertyTypeId).ToDictionary(x => x.Key, x => (IEnumerable)x); foreach (var propertyType in propertyTypes) { @@ -104,10 +104,14 @@ namespace Umbraco.Core.Persistence.Factories /// The properties to map /// /// out parameter indicating that one or more properties have been edited - /// out parameter containing a collection of edited cultures when the contentVariation varies by culture + /// + /// Out parameter containing a collection of edited cultures when the contentVariation varies by culture. + /// The value of this will be used to populate the edited cultures in the umbracoDocumentCultureVariation table. + /// /// public static IEnumerable BuildDtos(ContentVariation contentVariation, int currentVersionId, int publishedVersionId, IEnumerable properties, - ILanguageRepository languageRepository, out bool edited, out HashSet editedCultures) + ILanguageRepository languageRepository, out bool edited, + out HashSet editedCultures) { var propertyDataDtos = new List(); edited = false; @@ -130,6 +134,9 @@ namespace Umbraco.Core.Persistence.Factories // publishing = deal with edit and published values foreach (var propertyValue in property.Values) { + var isInvariantValue = propertyValue.Culture == null; + var isCultureValue = propertyValue.Culture != null && propertyValue.Segment == null; + // deal with published value if (propertyValue.PublishedValue != null && publishedVersionId > 0) propertyDataDtos.Add(BuildDto(publishedVersionId, property, languageRepository.GetIdByIsoCode(propertyValue.Culture), propertyValue.Segment, propertyValue.PublishedValue)); @@ -138,26 +145,36 @@ namespace Umbraco.Core.Persistence.Factories if (propertyValue.EditedValue != null) propertyDataDtos.Add(BuildDto(currentVersionId, property, languageRepository.GetIdByIsoCode(propertyValue.Culture), propertyValue.Segment, propertyValue.EditedValue)); + // property.Values will contain ALL of it's values, both variant and invariant which will be populated if the + // administrator has previously changed the property type to be variant vs invariant. + // We need to check for this scenario here because otherwise the editedCultures and edited flags + // will end up incorrectly set in the umbracoDocumentCultureVariation table so here we need to + // only process edited cultures based on the current value type and how the property varies. + // The above logic will still persist the currently saved property value for each culture in case the admin + // decides to swap the property's variance again, in which case the edited flag will be recalculated. + + if (property.PropertyType.VariesByCulture() && isInvariantValue || !property.PropertyType.VariesByCulture() && isCultureValue) + continue; + // use explicit equals here, else object comparison fails at comparing eg strings var sameValues = propertyValue.PublishedValue == null ? propertyValue.EditedValue == null : propertyValue.PublishedValue.Equals(propertyValue.EditedValue); + edited |= !sameValues; - if (entityVariesByCulture // cultures can be edited, ie CultureNeutral is supported - && propertyValue.Culture != null && propertyValue.Segment == null // and value is CultureNeutral - && !sameValues) // and edited and published are different + if (entityVariesByCulture && !sameValues) { - editedCultures.Add(propertyValue.Culture); // report culture as edited - } + if (isCultureValue) + { + editedCultures.Add(propertyValue.Culture); // report culture as edited + } + else if (isInvariantValue) + { + // flag culture as edited if it contains an edited invariant property + if (defaultCulture == null) + defaultCulture = languageRepository.GetDefaultIsoCode(); - // flag culture as edited if it contains an edited invariant property - if (propertyValue.Culture == null //invariant property - && !sameValues // and edited and published are different - && entityVariesByCulture) //only when the entity is variant - { - if (defaultCulture == null) - defaultCulture = languageRepository.GetDefaultIsoCode(); - - editedCultures.Add(defaultCulture); + editedCultures.Add(defaultCulture); + } } } } @@ -167,7 +184,7 @@ namespace Umbraco.Core.Persistence.Factories { // not publishing = only deal with edit values if (propertyValue.EditedValue != null) - propertyDataDtos.Add(BuildDto(currentVersionId, property, languageRepository.GetIdByIsoCode(propertyValue.Culture), propertyValue.Segment, propertyValue.EditedValue)); + propertyDataDtos.Add(BuildDto(currentVersionId, property, languageRepository.GetIdByIsoCode(propertyValue.Culture), propertyValue.Segment, propertyValue.EditedValue)); } edited = true; } diff --git a/src/Umbraco.Core/Persistence/Factories/PropertyGroupFactory.cs b/src/Umbraco.Core/Persistence/Factories/PropertyGroupFactory.cs index db8e2b20d9..dc1629e8f7 100644 --- a/src/Umbraco.Core/Persistence/Factories/PropertyGroupFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/PropertyGroupFactory.cs @@ -60,8 +60,10 @@ namespace Umbraco.Core.Persistence.Factories propertyType.Key = typeDto.UniqueId; propertyType.Name = typeDto.Name; propertyType.Mandatory = typeDto.Mandatory; + propertyType.MandatoryMessage = typeDto.MandatoryMessage; propertyType.SortOrder = typeDto.SortOrder; propertyType.ValidationRegExp = typeDto.ValidationRegExp; + propertyType.ValidationRegExpMessage = typeDto.ValidationRegExpMessage; propertyType.PropertyGroupId = new Lazy(() => tempGroupDto.Id); propertyType.CreateDate = createDate; propertyType.UpdateDate = updateDate; @@ -124,9 +126,11 @@ namespace Umbraco.Core.Persistence.Factories DataTypeId = propertyType.DataTypeId, Description = propertyType.Description, Mandatory = propertyType.Mandatory, + MandatoryMessage = propertyType.MandatoryMessage, Name = propertyType.Name, SortOrder = propertyType.SortOrder, ValidationRegExp = propertyType.ValidationRegExp, + ValidationRegExpMessage = propertyType.ValidationRegExpMessage, UniqueId = propertyType.Key, Variations = (byte)propertyType.Variations }; diff --git a/src/Umbraco.Core/Persistence/Factories/RelationFactory.cs b/src/Umbraco.Core/Persistence/Factories/RelationFactory.cs index 6abb858e94..d8f100cdbe 100644 --- a/src/Umbraco.Core/Persistence/Factories/RelationFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/RelationFactory.cs @@ -3,20 +3,11 @@ using Umbraco.Core.Persistence.Dtos; namespace Umbraco.Core.Persistence.Factories { - internal class RelationFactory + internal static class RelationFactory { - private readonly IRelationType _relationType; - - public RelationFactory(IRelationType relationType) + public static IRelation BuildEntity(RelationDto dto, IRelationType relationType) { - _relationType = relationType; - } - - #region Implementation of IEntityFactory - - public IRelation BuildEntity(RelationDto dto) - { - var entity = new Relation(dto.ParentId, dto.ChildId, _relationType); + var entity = new Relation(dto.ParentId, dto.ChildId, dto.ParentObjectType, dto.ChildObjectType, relationType); try { @@ -37,7 +28,7 @@ namespace Umbraco.Core.Persistence.Factories } } - public RelationDto BuildDto(IRelation entity) + public static RelationDto BuildDto(IRelation entity) { var dto = new RelationDto { @@ -54,6 +45,5 @@ namespace Umbraco.Core.Persistence.Factories return dto; } - #endregion } } diff --git a/src/Umbraco.Core/Persistence/Factories/RelationTypeFactory.cs b/src/Umbraco.Core/Persistence/Factories/RelationTypeFactory.cs index ca6928a0a1..edd87fec68 100644 --- a/src/Umbraco.Core/Persistence/Factories/RelationTypeFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/RelationTypeFactory.cs @@ -9,7 +9,7 @@ namespace Umbraco.Core.Persistence.Factories public static IRelationType BuildEntity(RelationTypeDto dto) { - var entity = new RelationType(dto.ChildObjectType, dto.ParentObjectType, dto.Alias); + var entity = new RelationType(dto.Name, dto.Alias, dto.Dual, dto.ChildObjectType, dto.ParentObjectType); try { @@ -17,8 +17,6 @@ namespace Umbraco.Core.Persistence.Factories entity.Id = dto.Id; entity.Key = dto.UniqueId; - entity.IsBidirectional = dto.Dual; - entity.Name = dto.Name; // reset dirty initial properties (U4-1946) entity.ResetDirtyProperties(false); diff --git a/src/Umbraco.Core/Persistence/FaultHandling/RetryLimitExceededException.cs b/src/Umbraco.Core/Persistence/FaultHandling/RetryLimitExceededException.cs index c537281dc9..a1a0db2983 100644 --- a/src/Umbraco.Core/Persistence/FaultHandling/RetryLimitExceededException.cs +++ b/src/Umbraco.Core/Persistence/FaultHandling/RetryLimitExceededException.cs @@ -4,59 +4,51 @@ using System.Runtime.Serialization; namespace Umbraco.Core.Persistence.FaultHandling { /// - /// The special type of exception that provides managed exit from a retry loop. The user code can use this - /// exception to notify the retry policy that no further retry attempts are required. + /// The special type of exception that provides managed exit from a retry loop. The user code can use this exception to notify the retry policy that no further retry attempts are required. /// + /// [Serializable] public sealed class RetryLimitExceededException : Exception { /// - /// Initializes a new instance of the class with a default error message. + /// Initializes a new instance of the class with a default error message. /// public RetryLimitExceededException() - : this("RetryLimitExceeded") - { - } + : base() + { } /// - /// Initializes a new instance of the class with a specified error message. + /// Initializes a new instance of the class with a specified error message. /// /// The message that describes the error. public RetryLimitExceededException(string message) : base(message) - { - } + { } /// - /// Initializes a new instance of the class with a reference to the inner exception - /// that is the cause of this exception. + /// Initializes a new instance of the class with a reference to the inner exception that is the cause of this exception. /// /// The exception that is the cause of the current exception. public RetryLimitExceededException(Exception innerException) - : base(innerException != null ? innerException.Message : "RetryLimitExceeded", innerException) - { - } + : base(null, innerException) + { } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The message that describes the error. /// The exception that is the cause of the current exception. public RetryLimitExceededException(string message, Exception innerException) : base(message, innerException) - { - } + { } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The that holds the serialized object data about the exception being thrown. - /// The that contains contextual information about the source or destination. - /// The parameter is null. - /// The class name is null or is zero (0). + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. private RetryLimitExceededException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } + { } } } diff --git a/src/Umbraco.Core/Persistence/Mappers/PropertyTypeMapper.cs b/src/Umbraco.Core/Persistence/Mappers/PropertyTypeMapper.cs index ab1869a7f5..6f22b61f9a 100644 --- a/src/Umbraco.Core/Persistence/Mappers/PropertyTypeMapper.cs +++ b/src/Umbraco.Core/Persistence/Mappers/PropertyTypeMapper.cs @@ -24,9 +24,11 @@ namespace Umbraco.Core.Persistence.Mappers DefineMap(nameof(PropertyType.DataTypeId), nameof(PropertyTypeDto.DataTypeId)); DefineMap(nameof(PropertyType.Description), nameof(PropertyTypeDto.Description)); DefineMap(nameof(PropertyType.Mandatory), nameof(PropertyTypeDto.Mandatory)); + DefineMap(nameof(PropertyType.MandatoryMessage), nameof(PropertyTypeDto.MandatoryMessage)); DefineMap(nameof(PropertyType.Name), nameof(PropertyTypeDto.Name)); DefineMap(nameof(PropertyType.SortOrder), nameof(PropertyTypeDto.SortOrder)); DefineMap(nameof(PropertyType.ValidationRegExp), nameof(PropertyTypeDto.ValidationRegExp)); + DefineMap(nameof(PropertyType.ValidationRegExpMessage), nameof(PropertyTypeDto.ValidationRegExpMessage)); DefineMap(nameof(PropertyType.PropertyEditorAlias), nameof(DataTypeDto.EditorAlias)); DefineMap(nameof(PropertyType.ValueStorageType), nameof(DataTypeDto.DbType)); } diff --git a/src/Umbraco.Core/Persistence/NPocoDatabaseExtensions-Bulk.cs b/src/Umbraco.Core/Persistence/NPocoDatabaseExtensions-Bulk.cs index 0574e37c4c..10db1ca18e 100644 --- a/src/Umbraco.Core/Persistence/NPocoDatabaseExtensions-Bulk.cs +++ b/src/Umbraco.Core/Persistence/NPocoDatabaseExtensions-Bulk.cs @@ -14,7 +14,21 @@ namespace Umbraco.Core.Persistence /// public static partial class NPocoDatabaseExtensions { - // TODO: review NPoco native InsertBulk to replace the code below + /// + /// Configures NPoco's SqlBulkCopyHelper to use the correct SqlConnection and SqlTransaction instances from the underlying RetryDbConnection and ProfiledDbTransaction + /// + /// + /// This is required to use NPoco's own method because we use wrapped DbConnection and DbTransaction instances. + /// NPoco's InsertBulk method only caters for efficient bulk inserting records for Sql Server, it does not cater for bulk inserting of records for + /// any other database type and in which case will just insert records one at a time. + /// NPoco's InsertBulk method also deals with updating the passed in entity's PK/ID once it's inserted whereas our own BulkInsertRecords methods + /// do not handle this scenario. + /// + public static void ConfigureNPocoBulkExtensions() + { + SqlBulkCopyHelper.SqlConnectionResolver = dbConn => GetTypedConnection(dbConn); + SqlBulkCopyHelper.SqlTransactionResolver = dbTran => GetTypedTransaction(dbTran); + } /// /// Bulk-inserts records within a transaction. @@ -235,7 +249,7 @@ namespace Umbraco.Core.Persistence //we need to add column mappings here because otherwise columns will be matched by their order and if the order of them are different in the DB compared //to the order in which they are declared in the model then this will not work, so instead we will add column mappings by name so that this explicitly uses //the names instead of their ordering. - foreach(var col in bulkReader.ColumnMappings) + foreach (var col in bulkReader.ColumnMappings) { copy.ColumnMappings.Add(col.DestinationColumn, col.DestinationColumn); } diff --git a/src/Umbraco.Core/Persistence/Repositories/IContentTypeRepositoryBase.cs b/src/Umbraco.Core/Persistence/Repositories/IContentTypeRepositoryBase.cs index 69b0698a96..254e04d2d5 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IContentTypeRepositoryBase.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IContentTypeRepositoryBase.cs @@ -26,5 +26,10 @@ namespace Umbraco.Core.Persistence.Repositories /// /// bool HasContainerInPath(string contentPath); + + /// + /// Returns true or false depending on whether content nodes have been created based on the provided content type id. + /// + bool HasContentNodes(int id); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs index afb419ebd6..3a44cb10b4 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IDataTypeRepository.cs @@ -7,5 +7,12 @@ namespace Umbraco.Core.Persistence.Repositories public interface IDataTypeRepository : IReadWriteQueryRepository { IEnumerable> Move(IDataType toMove, EntityContainer container); + + /// + /// Returns a dictionary of content type s and the property type aliases that use a + /// + /// + /// + IReadOnlyDictionary> FindUsages(int id); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index 69f6ef4c5f..a0ddcac8e6 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -1,4 +1,5 @@ -using System; +using NPoco; +using System; using System.Collections.Generic; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; @@ -15,10 +16,22 @@ namespace Umbraco.Core.Persistence.Repositories IEntitySlim Get(int id, Guid objectTypeId); IEntitySlim Get(Guid key, Guid objectTypeId); - IEnumerable GetAll(Guid objectType, params int[] ids); + IEnumerable GetAll(Guid objectType, params int[] ids); IEnumerable GetAll(Guid objectType, params Guid[] keys); + /// + /// Gets entities for a query + /// + /// + /// IEnumerable GetByQuery(IQuery query); + + /// + /// Gets entities for a query and a specific object type allowing the query to be slightly more optimized + /// + /// + /// + /// IEnumerable GetByQuery(IQuery query, Guid objectType); UmbracoObjectTypes GetObjectType(int id); @@ -30,7 +43,41 @@ namespace Umbraco.Core.Persistence.Repositories bool Exists(int id); bool Exists(Guid key); + /// + /// Gets paged entities for a query and a subset of object types + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// A callback providing the ability to customize the generated SQL used to retrieve entities + /// + /// + /// A collection of mixed entity types which would be of type , , , + /// + /// + IEnumerable GetPagedResultsByQuery( + IQuery query, Guid[] objectTypes, long pageIndex, int pageSize, out long totalRecords, + IQuery filter, Ordering ordering, Action> sqlCustomization = null); + + /// + /// Gets paged entities for a query and a specific object type + /// + /// + /// + /// + /// + /// + /// + /// + /// IEnumerable GetPagedResultsByQuery(IQuery query, Guid objectType, long pageIndex, int pageSize, out long totalRecords, IQuery filter, Ordering ordering); + + } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IRelationRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IRelationRepository.cs index 51d7656d8a..fc1be20e6f 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IRelationRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IRelationRepository.cs @@ -1,9 +1,33 @@ -using Umbraco.Core.Models; +using System; +using System.Collections.Generic; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Entities; +using Umbraco.Core.Persistence.Querying; +using Umbraco.Core.Services; namespace Umbraco.Core.Persistence.Repositories { public interface IRelationRepository : IReadWriteQueryRepository { + IEnumerable GetPagedRelationsByQuery(IQuery query, long pageIndex, int pageSize, out long totalRecords, Ordering ordering); + /// + /// Persist multiple at once + /// + /// + void Save(IEnumerable relations); + + /// + /// Deletes all relations for a parent for any specified relation type alias + /// + /// + /// + /// A list of relation types to match for deletion, if none are specified then all relations for this parent id are deleted + /// + void DeleteByParent(int parentId, params string[] relationTypeAliases); + + IEnumerable GetPagedParentEntitiesByChildId(int childId, long pageIndex, int pageSize, out long totalRecords, params Guid[] entityTypes); + + IEnumerable GetPagedChildEntitiesByParentId(int parentId, long pageIndex, int pageSize, out long totalRecords, params Guid[] entityTypes); } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/AuditRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/AuditRepository.cs index c25328b10c..0788594e3a 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/AuditRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/AuditRepository.cs @@ -174,7 +174,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement totalRecords = page.TotalItems; var items = page.Items.Select( - dto => new AuditItem(dto.Id, Enum.ParseOrNull(dto.Header) ?? AuditType.Custom, dto.UserId ?? Constants.Security.UnknownUserId, dto.EntityType, dto.Comment, dto.Parameters)).ToList(); + dto => new AuditItem(dto.NodeId, Enum.ParseOrNull(dto.Header) ?? AuditType.Custom, dto.UserId ?? Constants.Security.UnknownUserId, dto.EntityType, dto.Comment, dto.Parameters)).ToList(); // map the DateStamp for (var i = 0; i < items.Count; i++) diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentRepositoryBase.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentRepositoryBase.cs index aeb4c3774f..13b687eb4e 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentRepositoryBase.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentRepositoryBase.cs @@ -9,6 +9,7 @@ using Umbraco.Core.Composing; using Umbraco.Core.Events; using Umbraco.Core.Logging; using Umbraco.Core.Models; +using Umbraco.Core.Models.Editors; using Umbraco.Core.Models.Entities; using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Persistence.Dtos; @@ -24,26 +25,48 @@ namespace Umbraco.Core.Persistence.Repositories.Implement internal sealed class ContentRepositoryBase { /// + /// /// This is used for unit tests ONLY /// public static bool ThrowOnWarning = false; } internal abstract class ContentRepositoryBase : NPocoRepositoryBase, IContentRepository - where TEntity : class, IUmbracoEntity + where TEntity : class, IContentBase where TRepository : class, IRepository { - protected ContentRepositoryBase(IScopeAccessor scopeAccessor, AppCaches cache, ILanguageRepository languageRepository, ILogger logger) + private readonly Lazy _propertyEditors; + private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactories; + + /// + /// + /// + /// + /// + /// + /// + /// + /// Lazy property value collection - must be lazy because we have a circular dependency since some property editors require services, yet these services require property editors + /// + protected ContentRepositoryBase(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, + ILanguageRepository languageRepository, IRelationRepository relationRepository, IRelationTypeRepository relationTypeRepository, + Lazy propertyEditors, DataValueReferenceFactoryCollection dataValueReferenceFactories) : base(scopeAccessor, cache, logger) { LanguageRepository = languageRepository; + RelationRepository = relationRepository; + RelationTypeRepository = relationTypeRepository; + _propertyEditors = propertyEditors; + _dataValueReferenceFactories = dataValueReferenceFactories; } protected abstract TRepository This { get; } protected ILanguageRepository LanguageRepository { get; } + protected IRelationRepository RelationRepository { get; } + protected IRelationTypeRepository RelationTypeRepository { get; } - protected PropertyEditorCollection PropertyEditors => Current.PropertyEditors; // TODO: inject + protected PropertyEditorCollection PropertyEditors => _propertyEditors.Value; #region Versions @@ -512,31 +535,16 @@ namespace Umbraco.Core.Persistence.Repositories.Implement foreach (var a in allPropertyDataDtos) a.PropertyTypeDto = indexedPropertyTypeDtos[a.PropertyTypeId]; - // prefetch configuration for tag properties - var tagEditors = new Dictionary(); - foreach (var propertyTypeDto in indexedPropertyTypeDtos.Values) - { - var editorAlias = propertyTypeDto.DataTypeDto.EditorAlias; - var editorAttribute = PropertyEditors[editorAlias].GetTagAttribute(); - if (editorAttribute == null) continue; - var tagConfigurationSource = propertyTypeDto.DataTypeDto.Configuration; - var tagConfiguration = string.IsNullOrWhiteSpace(tagConfigurationSource) - ? new TagConfiguration() - : JsonConvert.DeserializeObject(tagConfigurationSource); - if (tagConfiguration.Delimiter == default) tagConfiguration.Delimiter = editorAttribute.Delimiter; - tagEditors[editorAlias] = tagConfiguration; - } - // now we have // - the definitions // - all property data dtos - // - tag editors + // - tag editors (Actually ... no we don't since i removed that code, but we don't need them anyways it seems) // and we need to build the proper property collections - return GetPropertyCollections(temps, allPropertyDataDtos, tagEditors); + return GetPropertyCollections(temps, allPropertyDataDtos); } - private IDictionary GetPropertyCollections(List> temps, IEnumerable allPropertyDataDtos, Dictionary tagConfigurations) + private IDictionary GetPropertyCollections(List> temps, IEnumerable allPropertyDataDtos) where T : class, IContentBase { var result = new Dictionary(); @@ -812,5 +820,56 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } #endregion + + protected void PersistRelations(TEntity entity) + { + // Get all references from our core built in DataEditors/Property Editors + // Along with seeing if deverlopers want to collect additional references from the DataValueReferenceFactories collection + var trackedRelations = new List(); + trackedRelations.AddRange(_dataValueReferenceFactories.GetAllReferences(entity.Properties, PropertyEditors)); + + //First delete all auto-relations for this entity + RelationRepository.DeleteByParent(entity.Id, Constants.Conventions.RelationTypes.AutomaticRelationTypes); + + if (trackedRelations.Count == 0) return; + + trackedRelations = trackedRelations.Distinct().ToList(); + var udiToGuids = trackedRelations.Select(x => x.Udi as GuidUdi) + .ToDictionary(x => (Udi)x, x => x.Guid); + + //lookup in the DB all INT ids for the GUIDs and chuck into a dictionary + var keyToIds = Database.Fetch(Sql().Select(x => x.NodeId, x => x.UniqueId).From().WhereIn(x => x.UniqueId, udiToGuids.Values)) + .ToDictionary(x => x.UniqueId, x => x.NodeId); + + var allRelationTypes = RelationTypeRepository.GetMany(Array.Empty()) + .ToDictionary(x => x.Alias, x => x); + + var toSave = trackedRelations.Select(rel => + { + if (!allRelationTypes.TryGetValue(rel.RelationTypeAlias, out var relationType)) + throw new InvalidOperationException($"The relation type {rel.RelationTypeAlias} does not exist"); + + if (!udiToGuids.TryGetValue(rel.Udi, out var guid)) + return null; // This shouldn't happen! + + if (!keyToIds.TryGetValue(guid, out var id)) + return null; // This shouldn't happen! + + return new Relation(entity.Id, id, relationType); + }).WhereNotNull(); + + // Save bulk relations + RelationRepository.Save(toSave); + + } + + private class NodeIdKey + { + [Column("id")] + public int NodeId { get; set; } + + [Column("uniqueId")] + public Guid UniqueId { get; set; } + } } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeCommonRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeCommonRepository.cs index 6b751eb8ff..7781e2e38a 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeCommonRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeCommonRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using NPoco; using Umbraco.Core.Cache; +using Umbraco.Core.Exceptions; using Umbraco.Core.Models; using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Persistence.Factories; @@ -90,7 +91,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement contentType = ContentTypeFactory.BuildContentTypeEntity(contentTypeDto); else if (contentTypeDto.NodeDto.NodeObjectType == Constants.ObjectTypes.MemberType) contentType = ContentTypeFactory.BuildMemberTypeEntity(contentTypeDto); - else throw new Exception("panic"); + else throw new PanicException($"The node object type {contentTypeDto.NodeDto.NodeObjectType} is not supported"); contentTypes.Add(contentType.Id, contentType); // map allowed content types @@ -296,10 +297,12 @@ namespace Umbraco.Core.Persistence.Repositories.Implement Id = dto.Id, Key = dto.UniqueId, Mandatory = dto.Mandatory, + MandatoryMessage = dto.MandatoryMessage, Name = dto.Name, PropertyGroupId = groupId.HasValue ? new Lazy(() => groupId.Value) : null, SortOrder = dto.SortOrder, ValidationRegExp = dto.ValidationRegExp, + ValidationRegExpMessage = dto.ValidationRegExpMessage, Variations = (ContentVariation)dto.Variations }; } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeRepository.cs index 9d77eb0990..359b967dab 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using NPoco; using Umbraco.Core.Cache; +using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; @@ -18,8 +19,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement /// internal class ContentTypeRepository : ContentTypeRepositoryBase, IContentTypeRepository { - public ContentTypeRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IContentTypeCommonRepository commonRepository) - : base(scopeAccessor, cache, logger, commonRepository) + public ContentTypeRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IContentTypeCommonRepository commonRepository, ILanguageRepository languageRepository) + : base(scopeAccessor, cache, logger, commonRepository, languageRepository) { } protected override bool SupportsPublishing => ContentType.SupportsPublishingConst; @@ -56,7 +57,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement { // the cache policy will always want everything // even GetMany(ids) gets everything and filters afterwards - if (ids.Any()) throw new Exception("panic"); + if (ids.Any()) throw new PanicException("There can be no ids specified"); return CommonRepository.GetAllTypes().OfType(); } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs index 22c9244d8f..6f714ff187 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs @@ -1,4 +1,5 @@ -using System; + +using System; using System.Collections.Generic; using System.Data; using System.Globalization; @@ -26,14 +27,15 @@ namespace Umbraco.Core.Persistence.Repositories.Implement internal abstract class ContentTypeRepositoryBase : NPocoRepositoryBase, IReadRepository where TEntity : class, IContentTypeComposition { - protected ContentTypeRepositoryBase(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IContentTypeCommonRepository commonRepository) + protected ContentTypeRepositoryBase(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IContentTypeCommonRepository commonRepository, ILanguageRepository languageRepository) : base(scopeAccessor, cache, logger) { CommonRepository = commonRepository; + LanguageRepository = languageRepository; } protected IContentTypeCommonRepository CommonRepository { get; } - + protected ILanguageRepository LanguageRepository { get; } protected abstract bool SupportsPublishing { get; } public IEnumerable> Move(TEntity moving, EntityContainer container) @@ -98,6 +100,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected void PersistNewBaseContentType(IContentTypeComposition entity) { + ValidateVariations(entity); + var dto = ContentTypeFactory.BuildContentTypeDto(entity); //Cannot add a duplicate content type @@ -163,11 +167,11 @@ AND umbracoNode.nodeObjectType = @objectType", foreach (var allowedContentType in entity.AllowedContentTypes) { Database.Insert(new ContentTypeAllowedContentTypeDto - { - Id = entity.Id, - AllowedId = allowedContentType.Id.Value, - SortOrder = allowedContentType.SortOrder - }); + { + Id = entity.Id, + AllowedId = allowedContentType.Id.Value, + SortOrder = allowedContentType.SortOrder + }); } @@ -214,6 +218,9 @@ AND umbracoNode.nodeObjectType = @objectType", protected void PersistUpdatedBaseContentType(IContentTypeComposition entity) { + CorrectPropertyTypeVariations(entity); + ValidateVariations(entity); + var dto = ContentTypeFactory.BuildContentTypeDto(entity); // ensure the alias is not used already @@ -370,7 +377,7 @@ AND umbracoNode.id <> @id", foreach (var propertyGroup in entity.PropertyGroups) { // insert or update group - var groupDto = PropertyGroupFactory.BuildGroupDto(propertyGroup,entity.Id); + var groupDto = PropertyGroupFactory.BuildGroupDto(propertyGroup, entity.Id); var groupId = propertyGroup.HasIdentity ? Database.Update(groupDto) : Convert.ToInt32(Database.Insert(groupDto)); @@ -388,7 +395,7 @@ AND umbracoNode.id <> @id", //check if the content type variation has been changed var contentTypeVariationDirty = entity.IsPropertyDirty("Variations"); - var oldContentTypeVariation = (ContentVariation) dtoPk.Variations; + var oldContentTypeVariation = (ContentVariation)dtoPk.Variations; var newContentTypeVariation = entity.Variations; var contentTypeVariationChanging = contentTypeVariationDirty && oldContentTypeVariation != newContentTypeVariation; if (contentTypeVariationChanging) @@ -404,26 +411,7 @@ AND umbracoNode.id <> @id", // note: this only deals with *local* property types, we're dealing w/compositions later below foreach (var propertyType in entity.PropertyTypes) { - if (contentTypeVariationChanging) - { - // content type is changing - switch (newContentTypeVariation) - { - case ContentVariation.Nothing: // changing to Nothing - // all property types must change to Nothing - propertyType.Variations = ContentVariation.Nothing; - break; - case ContentVariation.Culture: // changing to Culture - // all property types can remain Nothing - break; - case ContentVariation.CultureAndSegment: - case ContentVariation.Segment: - default: - throw new NotSupportedException(); // TODO: Support this - } - } - - // then, track each property individually + // track each property individually if (propertyType.IsPropertyDirty("Variations")) { // allocate the list only when needed @@ -449,23 +437,19 @@ AND umbracoNode.id <> @id", // via composition, with their original variations (ie not filtered by this // content type variations - we need this true value to make decisions. - foreach (var propertyType in ((ContentTypeCompositionBase) entity).RawComposedPropertyTypes) + propertyTypeVariationChanges = propertyTypeVariationChanges ?? new Dictionary(); + + foreach (var composedPropertyType in ((ContentTypeCompositionBase)entity).RawComposedPropertyTypes) { - if (propertyType.VariesBySegment() || newContentTypeVariation.VariesBySegment()) - throw new NotSupportedException(); // TODO: support this + if (composedPropertyType.Variations == ContentVariation.Nothing) continue; - if (propertyType.Variations == ContentVariation.Culture) - { - if (propertyTypeVariationChanges == null) - propertyTypeVariationChanges = new Dictionary(); + // Determine target variation of the composed property type. + // The composed property is only considered culture variant when the base content type is also culture variant. + // The composed property is only considered segment variant when the base content type is also segment variant. + // Example: Culture variant content type with a Culture+Segment variant property type will become ContentVariation.Culture + var target = newContentTypeVariation & composedPropertyType.Variations; - // if content type moves to Culture, property type becomes Culture here again - // if content type moves to Nothing, property type becomes Nothing here - if (newContentTypeVariation == ContentVariation.Culture) - propertyTypeVariationChanges[propertyType.Id] = (ContentVariation.Nothing, ContentVariation.Culture); - else if (newContentTypeVariation == ContentVariation.Nothing) - propertyTypeVariationChanges[propertyType.Id] = (ContentVariation.Culture, ContentVariation.Nothing); - } + propertyTypeVariationChanges[composedPropertyType.Id] = (composedPropertyType.Variations, target); } } @@ -506,7 +490,7 @@ AND umbracoNode.id <> @id", var impacted = GetImpactedContentTypes(entity, all); // if some property types have actually changed, move their variant data - if (propertyTypeVariationChanges != null) + if (propertyTypeVariationChanges?.Count > 0) MovePropertyTypeVariantData(propertyTypeVariationChanges, impacted); // deal with orphan properties: those that were in a deleted tab, @@ -518,6 +502,42 @@ AND umbracoNode.id <> @id", CommonRepository.ClearCache(); // always } + /// + /// Corrects the property type variations for the given entity + /// to make sure the property type variation is compatible with the + /// variation set on the entity itself. + /// + /// Entity to correct properties for + private void CorrectPropertyTypeVariations(IContentTypeComposition entity) + { + // Update property variations based on the content type variation + foreach (var propertyType in entity.PropertyTypes) + { + // Determine variation for the property type. + // The property is only considered culture variant when the base content type is also culture variant. + // The property is only considered segment variant when the base content type is also segment variant. + // Example: Culture variant content type with a Culture+Segment variant property type will become ContentVariation.Culture + propertyType.Variations = entity.Variations & propertyType.Variations; + } + } + + /// + /// Ensures that no property types are flagged for a variance that is not supported by the content type itself + /// + /// The entity for which the property types will be validated + private void ValidateVariations(IContentTypeComposition entity) + { + foreach (var prop in entity.PropertyTypes) + { + // The variation of a property is only allowed if all its variation flags + // are also set on the entity itself. It cannot set anything that is not also set by the content type. + // For example, when entity.Variations is set to Culture a property cannot be set to Segment. + var isValid = entity.Variations.HasFlag(prop.Variations); + if (!isValid) + throw new InvalidOperationException($"The property {prop.Alias} cannot have variations of {prop.Variations} with the content type variations of {entity.Variations}"); + } + } + private IEnumerable GetImpactedContentTypes(IContentTypeComposition contentType, IEnumerable all) { var impact = new List(); @@ -525,12 +545,12 @@ AND umbracoNode.id <> @id", var tree = new Dictionary>(); foreach (var x in all) - foreach (var y in x.ContentTypeComposition) - { - if (!tree.TryGetValue(y.Id, out var list)) - list = tree[y.Id] = new List(); - list.Add(x); - } + foreach (var y in x.ContentTypeComposition) + { + if (!tree.TryGetValue(y.Id, out var list)) + list = tree[y.Id] = new List(); + list.Add(x); + } var nset = new List(); do @@ -572,7 +592,7 @@ AND umbracoNode.id <> @id", // new property type, ignore if (!oldVariations.TryGetValue(propertyType.Id, out var oldVariationB)) continue; - var oldVariation = (ContentVariation) oldVariationB; // NPoco cannot fetch directly + var oldVariation = (ContentVariation)oldVariationB; // NPoco cannot fetch directly // only those property types that *actually* changed var newVariation = propertyType.Variations; @@ -636,25 +656,27 @@ AND umbracoNode.id <> @id", var impactedL = impacted.Select(x => x.Id).ToList(); //Group by the "To" variation so we can bulk update in the correct batches - foreach(var grouping in propertyTypeChanges.GroupBy(x => x.Value.ToVariation)) + foreach (var grouping in propertyTypeChanges.GroupBy(x => x.Value)) { var propertyTypeIds = grouping.Select(x => x.Key).ToList(); - var toVariation = grouping.Key; + var (FromVariation, ToVariation) = grouping.Key; - switch (toVariation) + var fromCultureEnabled = FromVariation.HasFlag(ContentVariation.Culture); + var toCultureEnabled = ToVariation.HasFlag(ContentVariation.Culture); + + if (!fromCultureEnabled && toCultureEnabled) { - case ContentVariation.Culture: - CopyPropertyData(null, defaultLanguageId, propertyTypeIds, impactedL); - CopyTagData(null, defaultLanguageId, propertyTypeIds, impactedL); - break; - case ContentVariation.Nothing: - CopyPropertyData(defaultLanguageId, null, propertyTypeIds, impactedL); - CopyTagData(defaultLanguageId, null, propertyTypeIds, impactedL); - break; - case ContentVariation.CultureAndSegment: - case ContentVariation.Segment: - default: - throw new NotSupportedException(); // TODO: Support this + // Culture has been enabled + CopyPropertyData(null, defaultLanguageId, propertyTypeIds, impactedL); + CopyTagData(null, defaultLanguageId, propertyTypeIds, impactedL); + RenormalizeDocumentEditedFlags(propertyTypeIds, impactedL); + } + else if (fromCultureEnabled && !toCultureEnabled) + { + // Culture has been disabled + CopyPropertyData(defaultLanguageId, null, propertyTypeIds, impactedL); + CopyTagData(defaultLanguageId, null, propertyTypeIds, impactedL); + RenormalizeDocumentEditedFlags(propertyTypeIds, impactedL); } } } @@ -666,78 +688,72 @@ AND umbracoNode.id <> @id", { var defaultLanguageId = GetDefaultLanguageId(); - switch (toVariation) + var cultureIsNotEnabled = !fromVariation.HasFlag(ContentVariation.Culture); + var cultureWillBeEnabled = toVariation.HasFlag(ContentVariation.Culture); + + if (cultureIsNotEnabled && cultureWillBeEnabled) { - case ContentVariation.Culture: + //move the names + //first clear out any existing names that might already exists under the default lang + //there's 2x tables to update - //move the names - //first clear out any existing names that might already exists under the default lang - //there's 2x tables to update + //clear out the versionCultureVariation table + var sqlSelect = Sql().Select(x => x.Id) + .From() + .InnerJoin().On(x => x.Id, x => x.VersionId) + .InnerJoin().On(x => x.NodeId, x => x.NodeId) + .Where(x => x.ContentTypeId == contentType.Id) + .Where(x => x.LanguageId == defaultLanguageId); + var sqlDelete = Sql() + .Delete() + .WhereIn(x => x.Id, sqlSelect); - //clear out the versionCultureVariation table - var sqlSelect = Sql().Select(x => x.Id) - .From() - .InnerJoin().On(x => x.Id, x => x.VersionId) - .InnerJoin().On(x => x.NodeId, x => x.NodeId) - .Where(x => x.ContentTypeId == contentType.Id) - .Where(x => x.LanguageId == defaultLanguageId); - var sqlDelete = Sql() - .Delete() - .WhereIn(x => x.Id, sqlSelect); + Database.Execute(sqlDelete); - Database.Execute(sqlDelete); + //clear out the documentCultureVariation table + sqlSelect = Sql().Select(x => x.Id) + .From() + .InnerJoin().On(x => x.NodeId, x => x.NodeId) + .Where(x => x.ContentTypeId == contentType.Id) + .Where(x => x.LanguageId == defaultLanguageId); + sqlDelete = Sql() + .Delete() + .WhereIn(x => x.Id, sqlSelect); - //clear out the documentCultureVariation table - sqlSelect = Sql().Select(x => x.Id) - .From() - .InnerJoin().On(x => x.NodeId, x => x.NodeId) - .Where(x => x.ContentTypeId == contentType.Id) - .Where(x => x.LanguageId == defaultLanguageId); - sqlDelete = Sql() - .Delete() - .WhereIn(x => x.Id, sqlSelect); + Database.Execute(sqlDelete); - Database.Execute(sqlDelete); + //now we need to insert names into these 2 tables based on the invariant data - //now we need to insert names into these 2 tables based on the invariant data + //insert rows into the versionCultureVariationDto table based on the data from contentVersionDto for the default lang + var cols = Sql().Columns(x => x.VersionId, x => x.Name, x => x.UpdateUserId, x => x.UpdateDate, x => x.LanguageId); + sqlSelect = Sql().Select(x => x.Id, x => x.Text, x => x.UserId, x => x.VersionDate) + .Append($", {defaultLanguageId}") //default language ID + .From() + .InnerJoin().On(x => x.NodeId, x => x.NodeId) + .Where(x => x.ContentTypeId == contentType.Id); + var sqlInsert = Sql($"INSERT INTO {ContentVersionCultureVariationDto.TableName} ({cols})").Append(sqlSelect); - //insert rows into the versionCultureVariationDto table based on the data from contentVersionDto for the default lang - var cols = Sql().Columns(x => x.VersionId, x => x.Name, x => x.UpdateUserId, x => x.UpdateDate, x => x.LanguageId); - sqlSelect = Sql().Select(x => x.Id, x => x.Text, x => x.UserId, x => x.VersionDate) - .Append($", {defaultLanguageId}") //default language ID - .From() - .InnerJoin().On(x => x.NodeId, x => x.NodeId) - .Where(x => x.ContentTypeId == contentType.Id); - var sqlInsert = Sql($"INSERT INTO {ContentVersionCultureVariationDto.TableName} ({cols})").Append(sqlSelect); + Database.Execute(sqlInsert); - Database.Execute(sqlInsert); + //insert rows into the documentCultureVariation table + cols = Sql().Columns(x => x.NodeId, x => x.Edited, x => x.Published, x => x.Name, x => x.Available, x => x.LanguageId); + sqlSelect = Sql().Select(x => x.NodeId, x => x.Edited, x => x.Published) + .AndSelect(x => x.Text) + .Append($", 1, {defaultLanguageId}") //make Available + default language ID + .From() + .InnerJoin().On(x => x.NodeId, x => x.NodeId) + .InnerJoin().On(x => x.NodeId, x => x.NodeId) + .Where(x => x.ContentTypeId == contentType.Id); + sqlInsert = Sql($"INSERT INTO {DocumentCultureVariationDto.TableName} ({cols})").Append(sqlSelect); - //insert rows into the documentCultureVariation table - cols = Sql().Columns(x => x.NodeId, x => x.Edited, x => x.Published, x => x.Name, x => x.Available, x => x.LanguageId); - sqlSelect = Sql().Select(x => x.NodeId, x => x.Edited, x => x.Published) - .AndSelect(x => x.Text) - .Append($", 1, {defaultLanguageId}") //make Available + default language ID - .From() - .InnerJoin().On(x => x.NodeId, x => x.NodeId) - .InnerJoin().On(x => x.NodeId, x => x.NodeId) - .Where(x => x.ContentTypeId == contentType.Id); - sqlInsert = Sql($"INSERT INTO {DocumentCultureVariationDto.TableName} ({cols})").Append(sqlSelect); + Database.Execute(sqlInsert); + } + else + { + //we don't need to move the names! this is because we always keep the invariant names with the name of the default language. - Database.Execute(sqlInsert); - - break; - case ContentVariation.Nothing: - - //we don't need to move the names! this is because we always keep the invariant names with the name of the default language. - - //however, if we were to move names, we could do this: BUT this doesn't work with SQLCE, for that we'd have to update row by row :( - // if we want these SQL statements back, look into GIT history - - break; - case ContentVariation.CultureAndSegment: - case ContentVariation.Segment: - default: - throw new NotSupportedException(); // TODO: Support this + //however, if we were to move names, we could do this: BUT this doesn't work with SQLCE, for that we'd have to update row by row :( + // if we want these SQL statements back, look into GIT history } } @@ -963,6 +979,205 @@ AND umbracoNode.id <> @id", Database.Execute(sqlDelete); } + + } + + /// + /// Re-normalizes the edited value in the umbracoDocumentCultureVariation and umbracoDocument table when variations are changed + /// + /// + /// + /// + /// If this is not done, then in some cases the "edited" value for a particular culture for a document will remain true when it should be false + /// if the property was changed to invariant. In order to do this we need to recalculate this value based on the values stored for each + /// property, culture and current/published version. + /// + private void RenormalizeDocumentEditedFlags(IReadOnlyCollection propertyTypeIds, IReadOnlyCollection contentTypeIds = null) + { + var defaultLang = LanguageRepository.GetDefaultId(); + + //This will build up a query to get the property values of both the current and the published version so that we can check + //based on the current variance of each item to see if it's 'edited' value should be true/false. + + var whereInArgsCount = propertyTypeIds.Count + (contentTypeIds?.Count ?? 0); + if (whereInArgsCount > 2000) + throw new NotSupportedException("Too many property/content types."); + + var propertySql = Sql() + .Select() + .AndSelect(x => x.NodeId, x => x.Current) + .AndSelect(x => x.Published) + .AndSelect(x => x.Variations) + .From() + .InnerJoin().On((left, right) => left.Id == right.VersionId) + .InnerJoin().On((left, right) => left.Id == right.PropertyTypeId); + + if (contentTypeIds != null) + { + propertySql.InnerJoin().On((c, cversion) => c.NodeId == cversion.NodeId); + } + + propertySql.LeftJoin().On((docversion, cversion) => cversion.Id == docversion.Id) + .Where((docversion, cversion) => cversion.Current || docversion.Published) + .WhereIn(x => x.PropertyTypeId, propertyTypeIds); + + if (contentTypeIds != null) + { + propertySql.WhereIn(x => x.ContentTypeId, contentTypeIds); + } + + propertySql + .OrderBy(x => x.NodeId) + .OrderBy(x => x.PropertyTypeId, x => x.LanguageId, x => x.VersionId); + + //keep track of this node/lang to mark or unmark a culture as edited + var editedLanguageVersions = new Dictionary<(int nodeId, int? langId), bool>(); + //keep track of which node to mark or unmark as edited + var editedDocument = new Dictionary(); + var nodeId = -1; + var propertyTypeId = -1; + + PropertyValueVersionDto pubRow = null; + + //This is a reader (Query), we are not fetching this all into memory so we cannot make any changes during this iteration, we are just collecting data. + //Published data will always come before Current data based on the version id sort. + //There will only be one published row (max) and one current row per property. + foreach (var row in Database.Query(propertySql)) + { + //make sure to reset on each node/property change + if (nodeId != row.NodeId || propertyTypeId != row.PropertyTypeId) + { + nodeId = row.NodeId; + propertyTypeId = row.PropertyTypeId; + pubRow = null; + } + + if (row.Published) + pubRow = row; + + if (row.Current) + { + var propVariations = (ContentVariation)row.Variations; + + //if this prop doesn't vary but the row has a lang assigned or vice versa, flag this as not edited + if (!propVariations.VariesByCulture() && row.LanguageId.HasValue + || propVariations.VariesByCulture() && !row.LanguageId.HasValue) + { + //Flag this as not edited for this node/lang if the key doesn't exist + if (!editedLanguageVersions.TryGetValue((row.NodeId, row.LanguageId), out _)) + editedLanguageVersions.Add((row.NodeId, row.LanguageId), false); + + //mark as false if the item doesn't exist, else coerce to true + editedDocument[row.NodeId] = editedDocument.TryGetValue(row.NodeId, out var edited) ? (edited |= false) : false; + } + else if (pubRow == null) + { + //this would mean that that this property is 'edited' since there is no published version + editedLanguageVersions[(row.NodeId, row.LanguageId)] = true; + editedDocument[row.NodeId] = true; + } + //compare the property values, if they differ from versions then flag the current version as edited + else if (IsPropertyValueChanged(pubRow, row)) + { + //Here we would check if the property is invariant, in which case the edited language should be indicated by the default lang + editedLanguageVersions[(row.NodeId, !propVariations.VariesByCulture() ? defaultLang : row.LanguageId)] = true; + editedDocument[row.NodeId] = true; + } + + //reset + pubRow = null; + } + } + + //lookup all matching rows in umbracoDocumentCultureVariation + var docCultureVariationsToUpdate = editedLanguageVersions.InGroupsOf(2000) + .SelectMany(_ => Database.Fetch( + Sql().Select().From() + .WhereIn(x => x.LanguageId, editedLanguageVersions.Keys.Select(x => x.langId).ToList()) + .WhereIn(x => x.NodeId, editedLanguageVersions.Keys.Select(x => x.nodeId)))) + //convert to dictionary with the same key type + .ToDictionary(x => (x.NodeId, (int?)x.LanguageId), x => x); + + var toUpdate = new List(); + foreach (var ev in editedLanguageVersions) + { + if (docCultureVariationsToUpdate.TryGetValue(ev.Key, out var docVariations)) + { + //check if it needs updating + if (docVariations.Edited != ev.Value) + { + docVariations.Edited = ev.Value; + toUpdate.Add(docVariations); + } + } + else if (ev.Key.langId.HasValue) + { + //This should never happen! If a property culture is flagged as edited then the culture must exist at the document level + throw new PanicException($"The existing DocumentCultureVariationDto was not found for node {ev.Key.nodeId} and language {ev.Key.langId}"); + } + } + + //Now bulk update the table DocumentCultureVariationDto, once for edited = true, another for edited = false + foreach (var editValue in toUpdate.GroupBy(x => x.Edited)) + { + Database.Execute(Sql().Update(u => u.Set(x => x.Edited, editValue.Key)) + .WhereIn(x => x.Id, editValue.Select(x => x.Id))); + } + + //Now bulk update the umbracoDocument table + foreach (var editValue in editedDocument.GroupBy(x => x.Value)) + { + Database.Execute(Sql().Update(u => u.Set(x => x.Edited, editValue.Key)) + .WhereIn(x => x.NodeId, editValue.Select(x => x.Key))); + } + } + + private static bool IsPropertyValueChanged(PropertyValueVersionDto pubRow, PropertyValueVersionDto row) + { + return !pubRow.TextValue.IsNullOrWhiteSpace() && pubRow.TextValue != row.TextValue + || !pubRow.VarcharValue.IsNullOrWhiteSpace() && pubRow.VarcharValue != row.VarcharValue + || pubRow.DateValue.HasValue && pubRow.DateValue != row.DateValue + || pubRow.DecimalValue.HasValue && pubRow.DecimalValue != row.DecimalValue + || pubRow.IntValue.HasValue && pubRow.IntValue != row.IntValue; + } + + private class NameCompareDto + { + public int NodeId { get; set; } + public int CurrentVersion { get; set; } + public int LanguageId { get; set; } + public string CurrentName { get; set; } + public string PublishedName { get; set; } + public int? PublishedVersion { get; set; } + public int Id { get; set; } // the Id of the DocumentCultureVariationDto + public bool Edited { get; set; } + } + + private class PropertyValueVersionDto + { + public int VersionId { get; set; } + public int PropertyTypeId { get; set; } + public int? LanguageId { get; set; } + public string Segment { get; set; } + public int? IntValue { get; set; } + + private decimal? _decimalValue; + [Column("decimalValue")] + public decimal? DecimalValue + { + get => _decimalValue; + set => _decimalValue = value?.Normalize(); + } + + public DateTime? DateValue { get; set; } + public string VarcharValue { get; set; } + public string TextValue { get; set; } + + public int NodeId { get; set; } + public bool Current { get; set; } + public bool Published { get; set; } + + public byte Variations { get; set; } } private void DeletePropertyType(int contentTypeId, int propertyTypeId) @@ -1108,6 +1323,17 @@ WHERE {Constants.DatabaseSchema.Tables.Content}.nodeId IN (@ids) AND cmsContentT return Database.ExecuteScalar(sql) > 0; } + /// + /// Returns true or false depending on whether content nodes have been created based on the provided content type id. + /// + public bool HasContentNodes(int id) + { + var sql = new Sql( + $"SELECT CASE WHEN EXISTS (SELECT * FROM {Constants.DatabaseSchema.Tables.Content} WHERE contentTypeId = @id) THEN 1 ELSE 0 END", + new { id }); + return Database.ExecuteScalar(sql) == 1; + } + protected override IEnumerable GetDeleteClauses() { // in theory, services should have ensured that content items of the given content type diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/DataTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/DataTypeRepository.cs index dac8fda5ec..9ccf6e9623 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/DataTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/DataTypeRepository.cs @@ -279,6 +279,28 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return moveInfo; } + public IReadOnlyDictionary> FindUsages(int id) + { + if (id == default) + return new Dictionary>(); + + var sql = Sql() + .Select(ct => ct.Select(node => node.NodeDto)) + .AndSelect(pt => Alias(pt.Alias, "ptAlias"), pt => Alias(pt.Name, "ptName")) + .From() + .InnerJoin().On(ct => ct.NodeId, pt => pt.ContentTypeId) + .InnerJoin().On(n => n.NodeId, ct => ct.NodeId) + .Where(pt => pt.DataTypeId == id) + .OrderBy(node => node.NodeId) + .AndBy(pt => pt.Alias); + + var dtos = Database.FetchOneToMany(ct => ct.PropertyTypes, sql); + + return dtos.ToDictionary( + x => (Udi)new GuidUdi(ObjectTypes.GetUdiType(x.NodeDto.NodeObjectType.Value), x.NodeDto.UniqueId).EnsureClosed(), + x => (IEnumerable)x.PropertyTypes.Select(p => p.Alias).ToList()); + } + private string EnsureUniqueNodeName(string nodeName, int id = 0) { var template = SqlContext.Templates.Get("Umbraco.Core.DataTypeDefinitionRepository.EnsureUniqueNodeName", tsql => tsql @@ -291,5 +313,24 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return SimilarNodeName.GetUniqueName(names, id, nodeName); } + + + [TableName(Constants.DatabaseSchema.Tables.ContentType)] + private class ContentTypeReferenceDto : ContentTypeDto + { + [ResultColumn] + [Reference(ReferenceType.Many)] + public List PropertyTypes { get; set; } + } + + [TableName(Constants.DatabaseSchema.Tables.PropertyType)] + private class PropertyTypeReferenceDto + { + [Column("ptAlias")] + public string Alias { get; set; } + + [Column("ptName")] + public string Name { get; set; } + } } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentBlueprintRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentBlueprintRepository.cs index d137d7ac76..e150b2e632 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentBlueprintRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentBlueprintRepository.cs @@ -2,6 +2,7 @@ using Umbraco.Core.Cache; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Logging; +using Umbraco.Core.PropertyEditors; using Umbraco.Core.Scoping; namespace Umbraco.Core.Persistence.Repositories.Implement @@ -17,8 +18,10 @@ namespace Umbraco.Core.Persistence.Repositories.Implement /// internal class DocumentBlueprintRepository : DocumentRepository, IDocumentBlueprintRepository { - public DocumentBlueprintRepository(IScopeAccessor scopeAccessor, AppCaches appCaches, ILogger logger, IContentTypeRepository contentTypeRepository, ITemplateRepository templateRepository, ITagRepository tagRepository, ILanguageRepository languageRepository) - : base(scopeAccessor, appCaches, logger, contentTypeRepository, templateRepository, tagRepository, languageRepository) + public DocumentBlueprintRepository(IScopeAccessor scopeAccessor, AppCaches appCaches, ILogger logger, + IContentTypeRepository contentTypeRepository, ITemplateRepository templateRepository, ITagRepository tagRepository, ILanguageRepository languageRepository, IRelationRepository relationRepository, IRelationTypeRepository relationTypeRepository, + Lazy propertyEditorCollection, DataValueReferenceFactoryCollection dataValueReferenceFactories) + : base(scopeAccessor, appCaches, logger, contentTypeRepository, templateRepository, tagRepository, languageRepository, relationRepository, relationTypeRepository, propertyEditorCollection, dataValueReferenceFactories) { } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentRepository.cs index 30a2927cc8..ccfa8209fb 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/DocumentRepository.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using NPoco; using Umbraco.Core.Cache; -using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; @@ -12,6 +11,7 @@ using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Persistence.Factories; using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.SqlSyntax; +using Umbraco.Core.PropertyEditors; using Umbraco.Core.Scoping; using Umbraco.Core.Services; @@ -30,8 +30,23 @@ namespace Umbraco.Core.Persistence.Repositories.Implement private readonly ContentByGuidReadRepository _contentByGuidReadRepository; private readonly IScopeAccessor _scopeAccessor; - public DocumentRepository(IScopeAccessor scopeAccessor, AppCaches appCaches, ILogger logger, IContentTypeRepository contentTypeRepository, ITemplateRepository templateRepository, ITagRepository tagRepository, ILanguageRepository languageRepository) - : base(scopeAccessor, appCaches, languageRepository, logger) + /// + /// Constructor + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// Lazy property value collection - must be lazy because we have a circular dependency since some property editors require services, yet these services require property editors + /// + public DocumentRepository(IScopeAccessor scopeAccessor, AppCaches appCaches, ILogger logger, + IContentTypeRepository contentTypeRepository, ITemplateRepository templateRepository, ITagRepository tagRepository, ILanguageRepository languageRepository, IRelationRepository relationRepository, IRelationTypeRepository relationTypeRepository, + Lazy propertyEditors, DataValueReferenceFactoryCollection dataValueReferenceFactories) + : base(scopeAccessor, appCaches, logger, languageRepository, relationRepository, relationTypeRepository, propertyEditors, dataValueReferenceFactories) { _contentTypeRepository = contentTypeRepository ?? throw new ArgumentNullException(nameof(contentTypeRepository)); _templateRepository = templateRepository ?? throw new ArgumentNullException(nameof(templateRepository)); @@ -229,6 +244,21 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return MapDtosToContent(Database.Fetch(sql), true); } + // TODO: This method needs to return a readonly version of IContent! The content returned + // from this method does not contain all of the data required to re-persist it and if that + // is attempted some odd things will occur. + // Either we create an IContentReadOnly (which ultimately we should for vNext so we can + // differentiate between methods that return entities that can be re-persisted or not), or + // in the meantime to not break API compatibility, we can add a property to IContentBase + // (or go further and have it on IUmbracoEntity): "IsReadOnly" and if that is true we throw + // an exception if that entity is passed to a Save method. + // Ideally we return "Slim" versions of content for all sorts of methods here and in ContentService. + // Perhaps another non-breaking alternative is to have new services like IContentServiceReadOnly + // which can return IContentReadOnly. + // We have the ability with `MapDtosToContent` to reduce the amount of data looked up for a + // content item. Ideally for paged data that populates list views, these would be ultra slim + // content items, there's no reason to populate those with really anything apart from property data, + // but until we do something like the above, we can't do that since it would be breaking and unclear. public override IEnumerable GetAllVersionsSlim(int nodeId, int skip, int take) { var sql = GetBaseQuery(QueryType.Many, false) @@ -236,7 +266,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement .OrderByDescending(x => x.Current) .AndByDescending(x => x.VersionDate); - return MapDtosToContent(Database.Fetch(sql), true, true).Skip(skip).Take(take); + return MapDtosToContent(Database.Fetch(sql), true, + // load bare minimum, need variants though since this is used to rollback with variants + false, false, false, true).Skip(skip).Take(take); } public override IContent GetVersion(int versionId) @@ -248,14 +280,63 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return dto == null ? null : MapDtoToContent(dto); } + // deletes a specific version + public override void DeleteVersion(int versionId) + { + // TODO: test object node type? + + // get the version we want to delete + var template = SqlContext.Templates.Get("Umbraco.Core.DocumentRepository.GetVersion", tsql => + tsql.Select() + .AndSelect() + .From() + .InnerJoin() + .On((c, d) => c.Id == d.Id) + .Where(x => x.Id == SqlTemplate.Arg("versionId")) + ); + var versionDto = Database.Fetch(template.Sql(new { versionId })).FirstOrDefault(); + + // nothing to delete + if (versionDto == null) + return; + + // don't delete the current or published version + if (versionDto.ContentVersionDto.Current) + throw new InvalidOperationException("Cannot delete the current version."); + else if (versionDto.Published) + throw new InvalidOperationException("Cannot delete the published version."); + + PerformDeleteVersion(versionDto.ContentVersionDto.NodeId, versionId); + } + + // deletes all versions of an entity, older than a date. + public override void DeleteVersions(int nodeId, DateTime versionDate) + { + // TODO: test object node type? + + // get the versions we want to delete, excluding the current one + var template = SqlContext.Templates.Get("Umbraco.Core.DocumentRepository.GetVersions", tsql => + tsql.Select() + .From() + .InnerJoin() + .On((c, d) => c.Id == d.Id) + .Where(x => x.NodeId == SqlTemplate.Arg("nodeId") && !x.Current && x.VersionDate < SqlTemplate.Arg("versionDate")) + .Where( x => !x.Published) + ); + var versionDtos = Database.Fetch(template.Sql(new { nodeId, versionDate })); + foreach (var versionDto in versionDtos) + PerformDeleteVersion(versionDto.NodeId, versionDto.Id); + } + protected override void PerformDeleteVersion(int id, int versionId) { // raise event first else potential FK issues OnUowRemovingVersion(new ScopedVersionEventArgs(AmbientScope, id, versionId)); Database.Delete("WHERE versionId = @versionId", new { versionId }); - Database.Delete("WHERE id = @versionId", new { versionId }); + Database.Delete("WHERE versionId = @versionId", new { versionId }); Database.Delete("WHERE id = @versionId", new { versionId }); + Database.Delete("WHERE id = @versionId", new { versionId }); } #endregion @@ -386,7 +467,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement Database.BulkInsertRecords(GetContentVariationDtos(entity, publishing)); // insert document variations - Database.BulkInsertRecords(GetDocumentVariationDtos(entity, publishing, editedCultures)); + Database.BulkInsertRecords(GetDocumentVariationDtos(entity, editedCultures)); } // refresh content @@ -419,6 +500,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement ClearEntityTags(entity, _tagRepository); } + PersistRelations(entity); + entity.ResetDirtyProperties(); // troubleshooting @@ -511,7 +594,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement entity.VersionId = documentVersionDto.Id = contentVersionDto.Id; // get the new id documentVersionDto.Published = false; // non-published version - Database.Insert(documentVersionDto); + Database.Insert(documentVersionDto); } // replace the property data (rather than updating) @@ -571,7 +654,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement Database.BulkInsertRecords(GetContentVariationDtos(entity, publishing)); // insert document variations - Database.BulkInsertRecords(GetDocumentVariationDtos(entity, publishing, editedCultures)); + Database.BulkInsertRecords(GetDocumentVariationDtos(entity, editedCultures)); } // refresh content @@ -622,6 +705,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement ClearEntityTags(entity, _tagRepository); } + PersistRelations(entity); + // TODO: note re. tags: explicitly unpublished entities have cleared tags, but masked or trashed entities *still* have tags in the db - so what? entity.ResetDirtyProperties(); @@ -875,32 +960,32 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override IEnumerable PerformGetByQuery(IQuery query) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override IEnumerable GetDeleteClauses() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override void PersistNewItem(IContent entity) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override void PersistUpdatedItem(IContent entity) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override Sql GetBaseQuery(bool isCount) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override string GetBaseWhereClause() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } } @@ -1007,7 +1092,12 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return base.ApplySystemOrdering(ref sql, ordering); } - private IEnumerable MapDtosToContent(List dtos, bool withCache = false, bool slim = false) + private IEnumerable MapDtosToContent(List dtos, + bool withCache = false, + bool loadProperties = true, + bool loadTemplates = true, + bool loadSchedule = true, + bool loadVariants = true) { var temps = new List>(); var contentTypes = new Dictionary(); @@ -1040,7 +1130,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement var c = content[i] = ContentBaseFactory.BuildEntity(dto, contentType); - if (!slim) + if (loadTemplates) { // need templates var templateId = dto.DocumentVersionDto.TemplateId; @@ -1065,49 +1155,71 @@ namespace Umbraco.Core.Persistence.Repositories.Implement temps.Add(temp); } - if (!slim) + Dictionary templates = null; + if (loadTemplates) { // load all required templates in 1 query, and index - var templates = _templateRepository.GetMany(templateIds.ToArray()) + templates = _templateRepository.GetMany(templateIds.ToArray()) .ToDictionary(x => x.Id, x => x); + } + IDictionary properties = null; + if (loadProperties) + { // load all properties for all documents from database in 1 query - indexed by version id - var properties = GetPropertyCollections(temps); - var schedule = GetContentSchedule(temps.Select(x => x.Content.Id).ToArray()); + properties = GetPropertyCollections(temps); + } - // assign templates and properties - foreach (var temp in temps) + var schedule = GetContentSchedule(temps.Select(x => x.Content.Id).ToArray()); + + // assign templates and properties + foreach (var temp in temps) + { + if (loadTemplates) { // set the template ID if it matches an existing template if (temp.Template1Id.HasValue && templates.ContainsKey(temp.Template1Id.Value)) temp.Content.TemplateId = temp.Template1Id; if (temp.Template2Id.HasValue && templates.ContainsKey(temp.Template2Id.Value)) temp.Content.PublishTemplateId = temp.Template2Id; + } + - // set properties + // set properties + if (loadProperties) + { if (properties.ContainsKey(temp.VersionId)) temp.Content.Properties = properties[temp.VersionId]; else throw new InvalidOperationException($"No property data found for version: '{temp.VersionId}'."); + } + if (loadSchedule) + { // load in the schedule if (schedule.TryGetValue(temp.Content.Id, out var s)) temp.Content.ContentSchedule = s; } + } - // set variations, if varying - temps = temps.Where(x => x.ContentType.VariesByCulture()).ToList(); - if (temps.Count > 0) + if (loadVariants) { - // load all variations for all documents from database, in one query - var contentVariations = GetContentVariations(temps); - var documentVariations = GetDocumentVariations(temps); - foreach (var temp in temps) - SetVariations(temp.Content, contentVariations, documentVariations); + // set variations, if varying + temps = temps.Where(x => x.ContentType.VariesByCulture()).ToList(); + if (temps.Count > 0) + { + // load all variations for all documents from database, in one query + var contentVariations = GetContentVariations(temps); + var documentVariations = GetDocumentVariations(temps); + foreach (var temp in temps) + SetVariations(temp.Content, contentVariations, documentVariations); + } } + - foreach(var c in content) + + foreach (var c in content) c.ResetDirtyProperties(false); // reset dirty initial properties (U4-1946) return content; @@ -1297,25 +1409,28 @@ namespace Umbraco.Core.Persistence.Repositories.Implement }; } - private IEnumerable GetDocumentVariationDtos(IContent content, bool publishing, HashSet editedCultures) + private IEnumerable GetDocumentVariationDtos(IContent content, HashSet editedCultures) { var allCultures = content.AvailableCultures.Union(content.PublishedCultures); // union = distinct foreach (var culture in allCultures) - yield return new DocumentCultureVariationDto + { + var dto = new DocumentCultureVariationDto { NodeId = content.Id, LanguageId = LanguageRepository.GetIdByIsoCode(culture) ?? throw new InvalidOperationException("Not a valid culture."), Culture = culture, Name = content.GetCultureName(culture) ?? content.GetPublishName(culture), - - // note: can't use IsCultureEdited at that point - hasn't been updated yet - see PersistUpdatedItem - Available = content.IsCultureAvailable(culture), Published = content.IsCulturePublished(culture), + // note: can't use IsCultureEdited at that point - hasn't been updated yet - see PersistUpdatedItem Edited = content.IsCultureAvailable(culture) && (!content.IsCulturePublished(culture) || (editedCultures != null && editedCultures.Contains(culture))) }; + + yield return dto; + } + } private class ContentVariation diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/EntityContainerRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/EntityContainerRepository.cs index 09fe949df1..505cbfc816 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/EntityContainerRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/EntityContainerRepository.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using NPoco; using Umbraco.Core.Cache; -using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Persistence.Dtos; @@ -129,6 +128,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override void PersistDeletedItem(EntityContainer entity) { + if (entity == null) throw new ArgumentNullException(nameof(entity)); EnsureContainerType(entity); var nodeDto = Database.FirstOrDefault(Sql().SelectAll() @@ -162,9 +162,11 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override void PersistNewItem(EntityContainer entity) { + if (entity == null) throw new ArgumentNullException(nameof(entity)); EnsureContainerType(entity); - if (string.IsNullOrWhiteSpace(entity.Name)) throw new ArgumentNullOrEmptyException("entity.Name"); + if (entity.Name == null) throw new InvalidOperationException("Entity name can't be null."); + if (string.IsNullOrWhiteSpace(entity.Name)) throw new InvalidOperationException("Entity name can't be empty or consist only of white-space characters."); entity.Name = entity.Name.Trim(); // guard against duplicates @@ -184,7 +186,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement .Where(dto => dto.NodeId == entity.ParentId && dto.NodeObjectType == entity.ContainerObjectType)); if (parentDto == null) - throw new NullReferenceException("Could not find parent container with id " + entity.ParentId); + throw new InvalidOperationException("Could not find parent container with id " + entity.ParentId); level = parentDto.Level; path = parentDto.Path; @@ -223,10 +225,12 @@ namespace Umbraco.Core.Persistence.Repositories.Implement // protected override void PersistUpdatedItem(EntityContainer entity) { + if (entity == null) throw new ArgumentNullException(nameof(entity)); EnsureContainerType(entity); + if (entity.Name == null) throw new InvalidOperationException("Entity name can't be null."); + if (string.IsNullOrWhiteSpace(entity.Name)) throw new InvalidOperationException("Entity name can't be empty or consist only of white-space characters."); entity.Name = entity.Name.Trim(); - if (string.IsNullOrWhiteSpace(entity.Name)) throw new ArgumentNullOrEmptyException("entity.Name"); // find container to update var nodeDto = Database.FirstOrDefault(Sql().SelectAll() @@ -255,7 +259,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement .Where(dto => dto.NodeId == entity.ParentId && dto.NodeObjectType == entity.ContainerObjectType)); if (parent == null) - throw new NullReferenceException("Could not find parent container with id " + entity.ParentId); + throw new InvalidOperationException("Could not find parent container with id " + entity.ParentId); nodeDto.Level = Convert.ToInt16(parent.Level + 1); nodeDto.Path = parent.Path + "," + nodeDto.NodeId; diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/EntityRepository.cs index 4a32e373c1..0eafebbfde 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/EntityRepository.cs @@ -35,26 +35,39 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected ISqlSyntaxProvider SqlSyntax => _scopeAccessor.AmbientScope.SqlContext.SqlSyntax; #region Repository - - // get a page of entities + public IEnumerable GetPagedResultsByQuery(IQuery query, Guid objectType, long pageIndex, int pageSize, out long totalRecords, IQuery filter, Ordering ordering) { - var isContent = objectType == Constants.ObjectTypes.Document || objectType == Constants.ObjectTypes.DocumentBlueprint; - var isMedia = objectType == Constants.ObjectTypes.Media; + return GetPagedResultsByQuery(query, new[] { objectType }, pageIndex, pageSize, out totalRecords, filter, ordering); + } - var sql = GetBaseWhere(isContent, isMedia, false, x => + // get a page of entities + public IEnumerable GetPagedResultsByQuery(IQuery query, Guid[] objectTypes, long pageIndex, int pageSize, out long totalRecords, + IQuery filter, Ordering ordering, Action> sqlCustomization = null) + { + var isContent = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Document || objectType == Constants.ObjectTypes.DocumentBlueprint); + var isMedia = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Media); + var isMember = objectTypes.Any(objectType => objectType == Constants.ObjectTypes.Member); + + var sql = GetBaseWhere(isContent, isMedia, isMember, false, s => { - if (filter == null) return; - foreach (var filterClause in filter.GetWhereClauses()) - x.Where(filterClause.Item1, filterClause.Item2); - }, objectType); + sqlCustomization?.Invoke(s); + + if (filter != null) + { + foreach (var filterClause in filter.GetWhereClauses()) + s.Where(filterClause.Item1, filterClause.Item2); + } + + + }, objectTypes); ordering = ordering ?? Ordering.ByDefault(); var translator = new SqlTranslator(sql, query); sql = translator.Translate(); - sql = AddGroupBy(isContent, isMedia, sql, ordering.IsEmpty); + sql = AddGroupBy(isContent, isMedia, isMember, sql, ordering.IsEmpty); if (!ordering.IsEmpty) { @@ -69,47 +82,31 @@ namespace Umbraco.Core.Persistence.Repositories.Implement // for content we must query for ContentEntityDto entities to produce the correct culture variant entity names var pageIndexToFetch = pageIndex + 1; IEnumerable dtos; - if(isContent) - { - var page = Database.Page(pageIndexToFetch, pageSize, sql); - dtos = page.Items; - totalRecords = page.TotalItems; - } - else if (isMedia) - { - var page = Database.Page(pageIndexToFetch, pageSize, sql); - dtos = page.Items; - totalRecords = page.TotalItems; - } - else - { - var page = Database.Page(pageIndexToFetch, pageSize, sql); - dtos = page.Items; - totalRecords = page.TotalItems; - } + var page = Database.Page(pageIndexToFetch, pageSize, sql); + dtos = page.Items; + totalRecords = page.TotalItems; - var entities = dtos.Select(x => BuildEntity(isContent, isMedia, x)).ToArray(); + var entities = dtos.Select(BuildEntity).ToArray(); - if (isContent) - BuildVariants(entities.Cast()); + BuildVariants(entities.OfType()); return entities; } public IEntitySlim Get(Guid key) { - var sql = GetBaseWhere(false, false, false, key); + var sql = GetBaseWhere(false, false, false, false, key); var dto = Database.FirstOrDefault(sql); - return dto == null ? null : BuildEntity(false, false, dto); + return dto == null ? null : BuildEntity(dto); } - private IEntitySlim GetEntity(Sql sql, bool isContent, bool isMedia) + private IEntitySlim GetEntity(Sql sql, bool isContent, bool isMedia, bool isMember) { //isContent is going to return a 1:M result now with the variants so we need to do different things if (isContent) { - var cdtos = Database.Fetch(sql); + var cdtos = Database.Fetch(sql); return cdtos.Count == 0 ? null : BuildVariants(BuildDocumentEntity(cdtos[0])); } @@ -120,7 +117,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement if (dto == null) return null; - var entity = BuildEntity(false, isMedia, dto); + var entity = BuildEntity(dto); return entity; } @@ -129,25 +126,27 @@ namespace Umbraco.Core.Persistence.Repositories.Implement { var isContent = objectTypeId == Constants.ObjectTypes.Document || objectTypeId == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectTypeId == Constants.ObjectTypes.Media; + var isMember = objectTypeId == Constants.ObjectTypes.Member; - var sql = GetFullSqlForEntityType(isContent, isMedia, objectTypeId, key); - return GetEntity(sql, isContent, isMedia); + var sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypeId, key); + return GetEntity(sql, isContent, isMedia, isMember); } public IEntitySlim Get(int id) { - var sql = GetBaseWhere(false, false, false, id); + var sql = GetBaseWhere(false, false, false, false, id); var dto = Database.FirstOrDefault(sql); - return dto == null ? null : BuildEntity(false, false, dto); + return dto == null ? null : BuildEntity(dto); } public IEntitySlim Get(int id, Guid objectTypeId) { var isContent = objectTypeId == Constants.ObjectTypes.Document || objectTypeId == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectTypeId == Constants.ObjectTypes.Media; + var isMember = objectTypeId == Constants.ObjectTypes.Member; - var sql = GetFullSqlForEntityType(isContent, isMedia, objectTypeId, id); - return GetEntity(sql, isContent, isMedia); + var sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypeId, id); + return GetEntity(sql, isContent, isMedia, isMember); } public IEnumerable GetAll(Guid objectType, params int[] ids) @@ -164,12 +163,12 @@ namespace Umbraco.Core.Persistence.Repositories.Implement : PerformGetAll(objectType); } - private IEnumerable GetEntities(Sql sql, bool isContent, bool isMedia) + private IEnumerable GetEntities(Sql sql, bool isContent, bool isMedia, bool isMember) { //isContent is going to return a 1:M result now with the variants so we need to do different things if (isContent) { - var cdtos = Database.Fetch(sql); + var cdtos = Database.Fetch(sql); return cdtos.Count == 0 ? Enumerable.Empty() @@ -180,7 +179,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement ? (IEnumerable)Database.Fetch(sql) : Database.Fetch(sql); - var entities = dtos.Select(x => BuildEntity(false, isMedia, x)).ToArray(); + var entities = dtos.Select(BuildEntity).ToArray(); return entities; } @@ -189,9 +188,10 @@ namespace Umbraco.Core.Persistence.Repositories.Implement { var isContent = objectType == Constants.ObjectTypes.Document || objectType == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectType == Constants.ObjectTypes.Media; + var isMember = objectType == Constants.ObjectTypes.Member; - var sql = GetFullSqlForEntityType(isContent, isMedia, objectType, filter); - return GetEntities(sql, isContent, isMedia); + var sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectType, filter); + return GetEntities(sql, isContent, isMedia, isMember); } public IEnumerable GetAllPaths(Guid objectType, params int[] ids) @@ -218,26 +218,27 @@ namespace Umbraco.Core.Persistence.Repositories.Implement public IEnumerable GetByQuery(IQuery query) { - var sqlClause = GetBase(false, false, null); + var sqlClause = GetBase(false, false, false, null); var translator = new SqlTranslator(sqlClause, query); var sql = translator.Translate(); - sql = AddGroupBy(false, false, sql, true); + sql = AddGroupBy(false, false, false, sql, true); var dtos = Database.Fetch(sql); - return dtos.Select(x => BuildEntity(false, false, x)).ToList(); + return dtos.Select(BuildEntity).ToList(); } public IEnumerable GetByQuery(IQuery query, Guid objectType) { var isContent = objectType == Constants.ObjectTypes.Document || objectType == Constants.ObjectTypes.DocumentBlueprint; var isMedia = objectType == Constants.ObjectTypes.Media; + var isMember = objectType == Constants.ObjectTypes.Member; - var sql = GetBaseWhere(isContent, isMedia, false, null, objectType); + var sql = GetBaseWhere(isContent, isMedia, isMember, false, null, new[] { objectType }); var translator = new SqlTranslator(sql, query); sql = translator.Translate(); - sql = AddGroupBy(isContent, isMedia, sql, true); + sql = AddGroupBy(isContent, isMedia, isMember, sql, true); - return GetEntities(sql, isContent, isMedia); + return GetEntities(sql, isContent, isMedia, isMember); } public UmbracoObjectTypes GetObjectType(int id) @@ -329,30 +330,30 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } // gets the full sql for a given object type and a given unique id - protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, Guid objectType, Guid uniqueId) + protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, Guid objectType, Guid uniqueId) { - var sql = GetBaseWhere(isContent, isMedia, false, objectType, uniqueId); - return AddGroupBy(isContent, isMedia, sql, true); + var sql = GetBaseWhere(isContent, isMedia, isMember, false, objectType, uniqueId); + return AddGroupBy(isContent, isMedia, isMember, sql, true); } // gets the full sql for a given object type and a given node id - protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, Guid objectType, int nodeId) + protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, Guid objectType, int nodeId) { - var sql = GetBaseWhere(isContent, isMedia, false, objectType, nodeId); - return AddGroupBy(isContent, isMedia, sql, true); + var sql = GetBaseWhere(isContent, isMedia, isMember, false, objectType, nodeId); + return AddGroupBy(isContent, isMedia, isMember, sql, true); } // gets the full sql for a given object type, with a given filter - protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, Guid objectType, Action> filter) + protected Sql GetFullSqlForEntityType(bool isContent, bool isMedia, bool isMember, Guid objectType, Action> filter) { - var sql = GetBaseWhere(isContent, isMedia, false, filter, objectType); - return AddGroupBy(isContent, isMedia, sql, true); + var sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, new[] { objectType }); + return AddGroupBy(isContent, isMedia, isMember, sql, true); } // gets the base SELECT + FROM [+ filter] sql // always from the 'current' content version - protected Sql GetBase(bool isContent, bool isMedia, Action> filter, bool isCount = false) - { + protected Sql GetBase(bool isContent, bool isMedia, bool isMember, Action> filter, bool isCount = false) + { var sql = Sql(); if (isCount) @@ -366,7 +367,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement .AndSelect(x => x.SortOrder, x => x.UniqueId, x => x.Text, x => x.NodeObjectType, x => x.CreateDate) .Append(", COUNT(child.id) AS children"); - if (isContent || isMedia) + if (isContent || isMedia || isMember) sql .AndSelect(x => Alias(x.Id, "versionId")) .AndSelect(x => x.Alias, x => x.Icon, x => x.Thumbnail, x => x.IsContainer, x => x.Variations); @@ -387,24 +388,24 @@ namespace Umbraco.Core.Persistence.Repositories.Implement sql .From(); - if (isContent || isMedia) + if (isContent || isMedia || isMember) { sql - .InnerJoin().On((left, right) => left.NodeId == right.NodeId && right.Current) - .InnerJoin().On((left, right) => left.NodeId == right.NodeId) - .InnerJoin().On((left, right) => left.ContentTypeId == right.NodeId); + .LeftJoin().On((left, right) => left.NodeId == right.NodeId && right.Current) + .LeftJoin().On((left, right) => left.NodeId == right.NodeId) + .LeftJoin().On((left, right) => left.ContentTypeId == right.NodeId); } if (isContent) { sql - .InnerJoin().On((left, right) => left.NodeId == right.NodeId); + .LeftJoin().On((left, right) => left.NodeId == right.NodeId); } if (isMedia) { sql - .InnerJoin().On((left, right) => left.Id == right.Id); + .LeftJoin().On((left, right) => left.Id == right.Id); } //Any LeftJoin statements need to come last @@ -422,49 +423,53 @@ namespace Umbraco.Core.Persistence.Repositories.Implement // gets the base SELECT + FROM [+ filter] + WHERE sql // for a given object type, with a given filter - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isCount, Action> filter, Guid objectType) + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Action> filter, Guid[] objectTypes) { - return GetBase(isContent, isMedia, filter, isCount) - .Where(x => x.NodeObjectType == objectType); + var sql = GetBase(isContent, isMedia, isMember, filter, isCount); + if (objectTypes.Length > 0) + { + sql.WhereIn(x => x.NodeObjectType, objectTypes); + } + return sql; } // gets the base SELECT + FROM + WHERE sql // for a given node id - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isCount, int id) + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, int id) { - var sql = GetBase(isContent, isMedia, null, isCount) + var sql = GetBase(isContent, isMedia, isMember, null, isCount) .Where(x => x.NodeId == id); - return AddGroupBy(isContent, isMedia, sql, true); + return AddGroupBy(isContent, isMedia, isMember, sql, true); } // gets the base SELECT + FROM + WHERE sql // for a given unique id - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isCount, Guid uniqueId) + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Guid uniqueId) { - var sql = GetBase(isContent, isMedia, null, isCount) + var sql = GetBase(isContent, isMedia, isMember, null, isCount) .Where(x => x.UniqueId == uniqueId); - return AddGroupBy(isContent, isMedia, sql, true); + return AddGroupBy(isContent, isMedia, isMember, sql, true); } // gets the base SELECT + FROM + WHERE sql // for a given object type and node id - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isCount, Guid objectType, int nodeId) + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Guid objectType, int nodeId) { - return GetBase(isContent, isMedia, null, isCount) + return GetBase(isContent, isMedia, isMember, null, isCount) .Where(x => x.NodeId == nodeId && x.NodeObjectType == objectType); } // gets the base SELECT + FROM + WHERE sql // for a given object type and unique id - protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isCount, Guid objectType, Guid uniqueId) + protected Sql GetBaseWhere(bool isContent, bool isMedia, bool isMember, bool isCount, Guid objectType, Guid uniqueId) { - return GetBase(isContent, isMedia, null, isCount) + return GetBase(isContent, isMedia, isMember, null, isCount) .Where(x => x.UniqueId == uniqueId && x.NodeObjectType == objectType); } // gets the GROUP BY / ORDER BY sql // required in order to count children - protected Sql AddGroupBy(bool isContent, bool isMedia, Sql sql, bool defaultSort) + protected Sql AddGroupBy(bool isContent, bool isMedia, bool isMember, Sql sql, bool defaultSort) { sql .GroupBy(x => x.NodeId, x => x.Trashed, x => x.ParentId, x => x.UserId, x => x.Level, x => x.Path) @@ -483,7 +488,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } - if (isContent || isMedia) + if (isContent || isMedia || isMember) sql .AndBy(x => x.Id) .AndBy(x => x.Alias, x => x.Icon, x => x.Thumbnail, x => x.IsContainer, x => x.Variations); @@ -499,8 +504,19 @@ namespace Umbraco.Core.Persistence.Repositories.Implement if (sql == null) throw new ArgumentNullException(nameof(sql)); if (ordering == null) throw new ArgumentNullException(nameof(ordering)); - // TODO: although this works for name, it probably doesn't work for others without an alias of some sort - var orderBy = ordering.OrderBy; + // TODO: although the default ordering string works for name, it wont work for others without a table or an alias of some sort + // As more things are attempted to be sorted we'll prob have to add more expressions here + string orderBy; + switch (ordering.OrderBy.ToUpperInvariant()) + { + case "PATH": + orderBy = SqlSyntax.GetQuotedColumn(NodeDto.TableName, "path"); + break; + + default: + orderBy = ordering.OrderBy; + break; + } if (ordering.Direction == Direction.Ascending) sql.OrderBy(orderBy); @@ -513,9 +529,17 @@ namespace Umbraco.Core.Persistence.Repositories.Implement #region Classes /// - /// The DTO used to fetch results for a content item with its variation info + /// The DTO used to fetch results for a generic content item which could be either a document, media or a member /// - private class ContentEntityDto : BaseDto + private class GenericContentEntityDto : DocumentEntityDto + { + public string MediaPath { get; set; } + } + + /// + /// The DTO used to fetch results for a document item with its variation info + /// + private class DocumentEntityDto : BaseDto { public ContentVariation Variations { get; set; } @@ -523,11 +547,21 @@ namespace Umbraco.Core.Persistence.Repositories.Implement public bool Edited { get; set; } } + /// + /// The DTO used to fetch results for a media item with its media path info + /// private class MediaEntityDto : BaseDto { public string MediaPath { get; set; } } + /// + /// The DTO used to fetch results for a member item + /// + private class MemberEntityDto : BaseDto + { + } + public class VariantInfoDto { public int NodeId { get; set; } @@ -574,12 +608,14 @@ namespace Umbraco.Core.Persistence.Repositories.Implement #region Factory - private EntitySlim BuildEntity(bool isContent, bool isMedia, BaseDto dto) + private EntitySlim BuildEntity(BaseDto dto) { - if (isContent) + if (dto.NodeObjectType == Constants.ObjectTypes.Document) return BuildDocumentEntity(dto); - if (isMedia) + if (dto.NodeObjectType == Constants.ObjectTypes.Media) return BuildMediaEntity(dto); + if (dto.NodeObjectType == Constants.ObjectTypes.Member) + return BuildMemberEntity(dto); // EntitySlim does not track changes var entity = new EntitySlim(); @@ -633,7 +669,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement var entity = new DocumentEntitySlim(); BuildContentEntity(entity, dto); - if (dto is ContentEntityDto contentDto) + if (dto is DocumentEntityDto contentDto) { // fill in the invariant info entity.Edited = contentDto.Edited; @@ -644,6 +680,19 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return entity; } + private MemberEntitySlim BuildMemberEntity(BaseDto dto) + { + // EntitySlim does not track changes + var entity = new MemberEntitySlim(); + BuildEntity(entity, dto); + + entity.ContentTypeAlias = dto.Alias; + entity.ContentTypeIcon = dto.Icon; + entity.ContentTypeThumbnail = dto.Thumbnail; + + return entity; + } + #endregion } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/LanguageRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/LanguageRepository.cs index 8429532b01..a905294417 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/LanguageRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/LanguageRepository.cs @@ -102,17 +102,17 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override IEnumerable GetDeleteClauses() { - var list = new List { //NOTE: There is no constraint between the Language and cmsDictionary/cmsLanguageText tables (?) // but we still need to remove them - "DELETE FROM cmsLanguageText WHERE languageId = @id", - "DELETE FROM umbracoPropertyData WHERE languageId = @id", - "DELETE FROM umbracoContentVersionCultureVariation WHERE languageId = @id", - "DELETE FROM umbracoDocumentCultureVariation WHERE languageId = @id", - "DELETE FROM umbracoLanguage WHERE id = @id", - "DELETE FROM " + Constants.DatabaseSchema.Tables.Tag + " WHERE languageId = @id" + "DELETE FROM " + Constants.DatabaseSchema.Tables.DictionaryValue + " WHERE languageId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE languageId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentVersionCultureVariation + " WHERE languageId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.DocumentCultureVariation + " WHERE languageId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.TagRelationship + " WHERE tagId IN (SELECT id FROM " + Constants.DatabaseSchema.Tables.Tag + " WHERE languageId = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.Tag + " WHERE languageId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.Language + " WHERE id = @id" }; return list; } @@ -182,6 +182,19 @@ namespace Umbraco.Core.Persistence.Repositories.Implement throw new InvalidOperationException($"Cannot save the default language ({entity.IsoCode}) as non-default. Make another language the default language instead."); } + if (entity.IsPropertyDirty(nameof(ILanguage.IsoCode))) + { + //if the iso code is changing, ensure there's not another lang with the same code already assigned + var sameCode = Sql() + .SelectCount() + .From() + .Where(x => x.IsoCode == entity.IsoCode && x.Id != entity.Id); + + var countOfSameCode = Database.ExecuteScalar(sameCode); + if (countOfSameCode > 0) + throw new InvalidOperationException($"Cannot update the language to a new culture: {entity.IsoCode} since that culture is already assigned to another language entity."); + } + // fallback cycles are detected at service level // update @@ -250,7 +263,6 @@ namespace Umbraco.Core.Persistence.Repositories.Implement lock (_codeIdMap) { if (_codeIdMap.TryGetValue(isoCode, out var id)) return id; - if (isoCode.Contains('-') && _codeIdMap.TryGetValue(isoCode.Split('-').First(), out var invariantId)) return invariantId; } if (throwOnNotFound) throw new ArgumentException($"Code {isoCode} does not correspond to an existing language.", nameof(isoCode)); diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs index 25828b8126..081efcfdf6 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MediaRepository.cs @@ -12,6 +12,7 @@ using Umbraco.Core.Models.Entities; using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Persistence.Factories; using Umbraco.Core.Persistence.Querying; +using Umbraco.Core.PropertyEditors; using Umbraco.Core.Scoping; using Umbraco.Core.Services; using static Umbraco.Core.Persistence.NPocoSqlExtensions.Statics; @@ -27,8 +28,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement private readonly ITagRepository _tagRepository; private readonly MediaByGuidReadRepository _mediaByGuidReadRepository; - public MediaRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IMediaTypeRepository mediaTypeRepository, ITagRepository tagRepository, ILanguageRepository languageRepository) - : base(scopeAccessor, cache, languageRepository, logger) + public MediaRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IMediaTypeRepository mediaTypeRepository, ITagRepository tagRepository, ILanguageRepository languageRepository, IRelationRepository relationRepository, IRelationTypeRepository relationTypeRepository, + Lazy propertyEditorCollection, DataValueReferenceFactoryCollection dataValueReferenceFactories) + : base(scopeAccessor, cache, logger, languageRepository, relationRepository, relationTypeRepository, propertyEditorCollection, dataValueReferenceFactories) { _mediaTypeRepository = mediaTypeRepository ?? throw new ArgumentNullException(nameof(mediaTypeRepository)); _tagRepository = tagRepository ?? throw new ArgumentNullException(nameof(tagRepository)); @@ -228,7 +230,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement entity.SanitizeEntityPropertiesForXmlStorage(); // create the dto - var dto = ContentBaseFactory.BuildDto(entity); + var dto = ContentBaseFactory.BuildDto(PropertyEditors, entity); // derive path and level from parent var parent = GetParentNodeDto(entity.ParentId); @@ -287,6 +289,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement // set tags SetEntityTags(entity, _tagRepository); + PersistRelations(entity); + OnUowRefreshedEntity(new ScopedEntityEventArgs(AmbientScope, entity)); entity.ResetDirtyProperties(); @@ -317,7 +321,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } // create the dto - var dto = ContentBaseFactory.BuildDto(entity); + var dto = ContentBaseFactory.BuildDto(PropertyEditors, entity); // update the node dto var nodeDto = dto.ContentDto.NodeDto; @@ -343,6 +347,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement SetEntityTags(entity, _tagRepository); + PersistRelations(entity); + OnUowRefreshedEntity(new ScopedEntityEventArgs(AmbientScope, entity)); entity.ResetDirtyProperties(); @@ -425,32 +431,32 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override IEnumerable PerformGetByQuery(IQuery query) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override IEnumerable GetDeleteClauses() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override void PersistNewItem(IMedia entity) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override void PersistUpdatedItem(IMedia entity) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override Sql GetBaseQuery(bool isCount) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override string GetBaseWhereClause() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } } @@ -542,6 +548,6 @@ namespace Umbraco.Core.Persistence.Repositories.Implement media.ResetDirtyProperties(false); return media; } - + } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MediaTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MediaTypeRepository.cs index 1abc75cf3a..512011b52c 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MediaTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MediaTypeRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using NPoco; using Umbraco.Core.Cache; +using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; @@ -17,8 +18,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement /// internal class MediaTypeRepository : ContentTypeRepositoryBase, IMediaTypeRepository { - public MediaTypeRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IContentTypeCommonRepository commonRepository) - : base(scopeAccessor, cache, logger, commonRepository) + public MediaTypeRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IContentTypeCommonRepository commonRepository, ILanguageRepository languageRepository) + : base(scopeAccessor, cache, logger, commonRepository, languageRepository) { } protected override bool SupportsPublishing => MediaType.SupportsPublishingConst; @@ -50,7 +51,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement { // the cache policy will always want everything // even GetMany(ids) gets everything and filters afterwards - if (ids.Any()) throw new Exception("panic"); + if (ids.Any()) throw new PanicException("There can be no ids specified"); return CommonRepository.GetAllTypes().OfType(); } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberRepository.cs index 1fc3568fc0..42e7d1c32f 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberRepository.cs @@ -10,6 +10,7 @@ using Umbraco.Core.Models.Entities; using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Persistence.Factories; using Umbraco.Core.Persistence.Querying; +using Umbraco.Core.PropertyEditors; using Umbraco.Core.Scoping; using Umbraco.Core.Services; using static Umbraco.Core.Persistence.NPocoSqlExtensions.Statics; @@ -25,8 +26,10 @@ namespace Umbraco.Core.Persistence.Repositories.Implement private readonly ITagRepository _tagRepository; private readonly IMemberGroupRepository _memberGroupRepository; - public MemberRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IMemberTypeRepository memberTypeRepository, IMemberGroupRepository memberGroupRepository, ITagRepository tagRepository, ILanguageRepository languageRepository) - : base(scopeAccessor, cache, languageRepository, logger) + public MemberRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, + IMemberTypeRepository memberTypeRepository, IMemberGroupRepository memberGroupRepository, ITagRepository tagRepository, ILanguageRepository languageRepository, IRelationRepository relationRepository, IRelationTypeRepository relationTypeRepository, + Lazy propertyEditors, DataValueReferenceFactoryCollection dataValueReferenceFactories) + : base(scopeAccessor, cache, logger, languageRepository, relationRepository, relationTypeRepository, propertyEditors, dataValueReferenceFactories) { _memberTypeRepository = memberTypeRepository ?? throw new ArgumentNullException(nameof(memberTypeRepository)); _tagRepository = tagRepository ?? throw new ArgumentNullException(nameof(tagRepository)); @@ -133,7 +136,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement // joining the type so we can do a query against the member type - not sure if this adds much overhead or not? // the execution plan says it doesn't so we'll go with that and in that case, it might be worth joining the content - // types by default on the document and media repo's so we can query by content type there too. + // types by default on the document and media repos so we can query by content type there too. .InnerJoin().On(left => left.ContentTypeId, right => right.NodeId); sql.Where(x => x.NodeObjectType == NodeObjectTypeId); @@ -321,6 +324,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement SetEntityTags(entity, _tagRepository); + PersistRelations(entity); + OnUowRefreshedEntity(new ScopedEntityEventArgs(AmbientScope, entity)); entity.ResetDirtyProperties(); @@ -386,6 +391,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement SetEntityTags(entity, _tagRepository); + PersistRelations(entity); + OnUowRefreshedEntity(new ScopedEntityEventArgs(AmbientScope, entity)); entity.ResetDirtyProperties(); @@ -546,6 +553,15 @@ namespace Umbraco.Core.Persistence.Repositories.Implement if (ordering.OrderBy.InvariantEquals("userName")) return SqlSyntax.GetFieldName(x => x.LoginName); + if (ordering.OrderBy.InvariantEquals("updateDate")) + return SqlSyntax.GetFieldName(x => x.VersionDate); + + if (ordering.OrderBy.InvariantEquals("createDate")) + return SqlSyntax.GetFieldName(x => x.CreateDate); + + if (ordering.OrderBy.InvariantEquals("contentTypeAlias")) + return SqlSyntax.GetFieldName(x => x.Alias); + return base.ApplySystemOrdering(ref sql, ordering); } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberTypeRepository.cs index d96854743e..c3b95dbd8f 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/MemberTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/MemberTypeRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using NPoco; using Umbraco.Core.Cache; +using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; @@ -18,8 +19,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement /// internal class MemberTypeRepository : ContentTypeRepositoryBase, IMemberTypeRepository { - public MemberTypeRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IContentTypeCommonRepository commonRepository) - : base(scopeAccessor, cache, logger, commonRepository) + public MemberTypeRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, IContentTypeCommonRepository commonRepository, ILanguageRepository languageRepository) + : base(scopeAccessor, cache, logger, commonRepository, languageRepository) { } protected override bool SupportsPublishing => MemberType.SupportsPublishingConst; @@ -57,7 +58,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement { // the cache policy will always want everything // even GetMany(ids) gets everything and filters afterwards - if (ids.Any()) throw new Exception("panic"); + if (ids.Any()) throw new PanicException("There can be no ids specified"); return CommonRepository.GetAllTypes().OfType(); } @@ -224,8 +225,17 @@ namespace Umbraco.Core.Persistence.Repositories.Implement if (builtinProperties.ContainsKey(propertyType.Alias)) { //this reset's its current data type reference which will be re-assigned based on the property editor assigned on the next line - propertyType.DataTypeId = 0; - propertyType.DataTypeKey = default; + var propDefinition = builtinProperties[propertyType.Alias]; + if (propDefinition != null) + { + propertyType.DataTypeId = propDefinition.DataTypeId; + propertyType.DataTypeKey = propDefinition.DataTypeKey; + } + else + { + propertyType.DataTypeId = 0; + propertyType.DataTypeKey = default; + } } } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/PermissionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/PermissionRepository.cs index b4fd86c567..259f0b89c0 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/PermissionRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/PermissionRepository.cs @@ -252,27 +252,27 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override ContentPermissionSet PerformGet(int id) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override IEnumerable PerformGetAll(params int[] ids) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override IEnumerable PerformGetByQuery(IQuery query) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override Sql GetBaseQuery(bool isCount) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override string GetBaseWhereClause() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override IEnumerable GetDeleteClauses() @@ -280,11 +280,11 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return new List(); } - protected override Guid NodeObjectTypeId => throw new WontImplementException(); + protected override Guid NodeObjectTypeId => throw new InvalidOperationException("This property won't be implemented."); protected override void PersistDeletedItem(ContentPermissionSet entity) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } #endregion diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/RedirectUrlRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/RedirectUrlRepository.cs index baac02b6bf..acf6bb7df2 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/RedirectUrlRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/RedirectUrlRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using NPoco; using Umbraco.Core.Cache; using Umbraco.Core.Logging; @@ -105,7 +106,7 @@ JOIN umbracoNode ON umbracoRedirectUrl.contentKey=umbracoNode.uniqueID"); CreateDateUtc = redirectUrl.CreateDateUtc, Url = redirectUrl.Url, Culture = redirectUrl.Culture, - UrlHash = redirectUrl.Url.ToSHA1() + UrlHash = redirectUrl.Url.GenerateHash() }; } @@ -134,7 +135,7 @@ JOIN umbracoNode ON umbracoRedirectUrl.contentKey=umbracoNode.uniqueID"); public IRedirectUrl Get(string url, Guid contentKey, string culture) { - var urlHash = url.ToSHA1(); + var urlHash = url.GenerateHash(); var sql = GetBaseQuery(false).Where(x => x.Url == url && x.UrlHash == urlHash && x.ContentKey == contentKey && x.Culture == culture); var dto = Database.Fetch(sql).FirstOrDefault(); return dto == null ? null : Map(dto); @@ -157,7 +158,7 @@ JOIN umbracoNode ON umbracoRedirectUrl.contentKey=umbracoNode.uniqueID"); public IRedirectUrl GetMostRecentUrl(string url) { - var urlHash = url.ToSHA1(); + var urlHash = url.GenerateHash(); var sql = GetBaseQuery(false) .Where(x => x.Url == url && x.UrlHash == urlHash) .OrderByDescending(x => x.CreateDateUtc); diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/RelationRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/RelationRepository.cs index 4b4af505b8..56a6336f75 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/RelationRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/RelationRepository.cs @@ -6,10 +6,14 @@ using Umbraco.Core.Cache; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Persistence.Dtos; using Umbraco.Core.Persistence.Factories; using Umbraco.Core.Persistence.Querying; +using Umbraco.Core.Persistence.SqlSyntax; using Umbraco.Core.Scoping; +using Umbraco.Core.Services; +using static Umbraco.Core.Persistence.NPocoSqlExtensions.Statics; namespace Umbraco.Core.Persistence.Repositories.Implement { @@ -19,11 +23,13 @@ namespace Umbraco.Core.Persistence.Repositories.Implement internal class RelationRepository : NPocoRepositoryBase, IRelationRepository { private readonly IRelationTypeRepository _relationTypeRepository; + private readonly IEntityRepository _entityRepository; - public RelationRepository(IScopeAccessor scopeAccessor, ILogger logger, IRelationTypeRepository relationTypeRepository) + public RelationRepository(IScopeAccessor scopeAccessor, ILogger logger, IRelationTypeRepository relationTypeRepository, IEntityRepository entityRepository) : base(scopeAccessor, AppCaches.NoCache, logger) { _relationTypeRepository = relationTypeRepository; + _entityRepository = entityRepository; } #region Overrides of RepositoryBase @@ -39,10 +45,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement var relationType = _relationTypeRepository.Get(dto.RelationType); if (relationType == null) - throw new Exception(string.Format("RelationType with Id: {0} doesn't exist", dto.RelationType)); + throw new InvalidOperationException(string.Format("RelationType with Id: {0} doesn't exist", dto.RelationType)); - var factory = new RelationFactory(relationType); - return DtoToEntity(dto, factory); + return DtoToEntity(dto, relationType); } protected override IEnumerable PerformGetAll(params int[] ids) @@ -67,26 +72,17 @@ namespace Umbraco.Core.Persistence.Repositories.Implement private IEnumerable DtosToEntities(IEnumerable dtos) { - // in most cases, the relation type will be the same for all of them, - // plus we've ordered the relations by type, so try to allocate as few - // factories as possible - bearing in mind that relation types are cached - RelationFactory factory = null; - var relationTypeId = -1; + //NOTE: This is N+1, BUT ALL relation types are cached so shouldn't matter - return dtos.Select(x => - { - if (relationTypeId != x.RelationType) - factory = new RelationFactory(_relationTypeRepository.Get(relationTypeId = x.RelationType)); - return DtoToEntity(x, factory); - }).ToList(); + return dtos.Select(x => DtoToEntity(x, _relationTypeRepository.Get(x.RelationType))).ToList(); } - private static IRelation DtoToEntity(RelationDto dto, RelationFactory factory) + private static IRelation DtoToEntity(RelationDto dto, IRelationType relationType) { - var entity = factory.BuildEntity(dto); + var entity = RelationFactory.BuildEntity(dto, relationType); // reset dirty initial properties (U4-1946) - ((BeingDirtyBase)entity).ResetDirtyProperties(false); + entity.ResetDirtyProperties(false); return entity; } @@ -97,14 +93,18 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override Sql GetBaseQuery(bool isCount) { - var sql = Sql(); + if (isCount) + { + return Sql().SelectCount().From(); + } - sql = isCount - ? sql.SelectCount() - : sql.Select(); + var sql = Sql().Select() + .AndSelect("uchild", x => Alias(x.NodeObjectType, "childObjectType")) + .AndSelect("uparent", x => Alias(x.NodeObjectType, "parentObjectType")) + .From() + .InnerJoin("uchild").On((rel, node) => rel.ChildId == node.NodeId, aliasRight: "uchild") + .InnerJoin("uparent").On((rel, node) => rel.ParentId == node.NodeId, aliasRight: "uparent"); - sql - .From(); return sql; } @@ -136,11 +136,12 @@ namespace Umbraco.Core.Persistence.Repositories.Implement { entity.AddingEntity(); - var factory = new RelationFactory(entity.RelationType); - var dto = factory.BuildDto(entity); + var dto = RelationFactory.BuildDto(entity); var id = Convert.ToInt32(Database.Insert(dto)); + entity.Id = id; + PopulateObjectTypes(entity); entity.ResetDirtyProperties(); } @@ -149,13 +150,192 @@ namespace Umbraco.Core.Persistence.Repositories.Implement { entity.UpdatingEntity(); - var factory = new RelationFactory(entity.RelationType); - var dto = factory.BuildDto(entity); + var dto = RelationFactory.BuildDto(entity); Database.Update(dto); + PopulateObjectTypes(entity); + entity.ResetDirtyProperties(); } #endregion + + /// + /// Used for joining the entity query with relations for the paging methods + /// + /// + private void SqlJoinRelations(Sql sql) + { + // add left joins for relation tables (this joins on both child or parent, so beware that this will normally return entities for + // both sides of the relation type unless the IUmbracoEntity query passed in filters one side out). + sql.LeftJoin().On((left, right) => left.NodeId == right.ChildId || left.NodeId == right.ParentId); + sql.LeftJoin().On((left, right) => left.RelationType == right.Id); + } + + public IEnumerable GetPagedParentEntitiesByChildId(int childId, long pageIndex, int pageSize, out long totalRecords, params Guid[] entityTypes) + { + // var contentObjectTypes = new[] { Constants.ObjectTypes.Document, Constants.ObjectTypes.Media, Constants.ObjectTypes.Member } + // we could pass in the contentObjectTypes so that the entity repository sql is configured to do full entity lookups so that we get the full data + // required to populate content, media or members, else we get the bare minimum data needed to populate an entity. BUT if we do this it + // means that the SQL is less efficient and returns data that is probably not needed for what we need this lookup for. For the time being we + // will just return the bare minimum entity data. + + return _entityRepository.GetPagedResultsByQuery(Query(), entityTypes, pageIndex, pageSize, out totalRecords, null, null, sql => + { + SqlJoinRelations(sql); + + sql.Where(rel => rel.ChildId == childId); + sql.Where((rel, node) => rel.ParentId == childId || node.NodeId != childId); + }); + } + + public IEnumerable GetPagedChildEntitiesByParentId(int parentId, long pageIndex, int pageSize, out long totalRecords, params Guid[] entityTypes) + { + // var contentObjectTypes = new[] { Constants.ObjectTypes.Document, Constants.ObjectTypes.Media, Constants.ObjectTypes.Member } + // we could pass in the contentObjectTypes so that the entity repository sql is configured to do full entity lookups so that we get the full data + // required to populate content, media or members, else we get the bare minimum data needed to populate an entity. BUT if we do this it + // means that the SQL is less efficient and returns data that is probably not needed for what we need this lookup for. For the time being we + // will just return the bare minimum entity data. + + return _entityRepository.GetPagedResultsByQuery(Query(), entityTypes, pageIndex, pageSize, out totalRecords, null, null, sql => + { + SqlJoinRelations(sql); + + sql.Where(rel => rel.ParentId == parentId); + sql.Where((rel, node) => rel.ChildId == parentId || node.NodeId != parentId); + }); + } + + public void Save(IEnumerable relations) + { + foreach (var hasIdentityGroup in relations.GroupBy(r => r.HasIdentity)) + { + if (hasIdentityGroup.Key) + { + // Do updates, we can't really do a bulk update so this is still a 1 by 1 operation + // however we can bulk populate the object types. It might be possible to bulk update + // with SQL but would be pretty ugly and we're not really too worried about that for perf, + // it's the bulk inserts we care about. + var asArray = hasIdentityGroup.ToArray(); + foreach (var relation in hasIdentityGroup) + { + relation.UpdatingEntity(); + var dto = RelationFactory.BuildDto(relation); + Database.Update(dto); + } + PopulateObjectTypes(asArray); + } + else + { + // Do bulk inserts + var entitiesAndDtos = hasIdentityGroup.ToDictionary( + r => // key = entity + { + r.AddingEntity(); + return r; + }, + RelationFactory.BuildDto); // value = DTO + + // Use NPoco's own InsertBulk command which will automatically re-populate the new Ids on the entities, our own + // BulkInsertRecords does not cater for this. + Database.InsertBulk(entitiesAndDtos.Values); + + // All dtos now have IDs assigned + foreach (var de in entitiesAndDtos) + { + // re-assign ID to the entity + de.Key.Id = de.Value.Id; + } + + PopulateObjectTypes(entitiesAndDtos.Keys.ToArray()); + } + } + } + + public IEnumerable GetPagedRelationsByQuery(IQuery query, long pageIndex, int pageSize, out long totalRecords, Ordering ordering) + { + var sql = GetBaseQuery(false); + + if (ordering == null || ordering.IsEmpty) + ordering = Ordering.By(SqlSyntax.GetQuotedColumn(Constants.DatabaseSchema.Tables.Relation, "id")); + + var translator = new SqlTranslator(sql, query); + sql = translator.Translate(); + + // apply ordering + ApplyOrdering(ref sql, ordering); + + var pageIndexToFetch = pageIndex + 1; + var page = Database.Page(pageIndexToFetch, pageSize, sql); + var dtos = page.Items; + totalRecords = page.TotalItems; + + var relTypes = _relationTypeRepository.GetMany(dtos.Select(x => x.RelationType).Distinct().ToArray()) + .ToDictionary(x => x.Id, x => x); + + var result = dtos.Select(r => + { + if (!relTypes.TryGetValue(r.RelationType, out var relType)) + throw new InvalidOperationException(string.Format("RelationType with Id: {0} doesn't exist", r.RelationType)); + return DtoToEntity(r, relType); + }).ToList(); + + return result; + } + + + public void DeleteByParent(int parentId, params string[] relationTypeAliases) + { + var subQuery = Sql().Select(x => x.Id) + .From() + .InnerJoin().On(x => x.RelationType, x => x.Id) + .Where(x => x.ParentId == parentId); + + if (relationTypeAliases.Length > 0) + { + subQuery.WhereIn(x => x.Alias, relationTypeAliases); + } + + Database.Execute(Sql().Delete().WhereIn(x => x.Id, subQuery)); + } + + /// + /// Used to populate the object types after insert/update + /// + /// + private void PopulateObjectTypes(params IRelation[] entities) + { + var entityIds = entities.Select(x => x.ParentId).Concat(entities.Select(y => y.ChildId)).Distinct(); + + var nodes = Database.Fetch(Sql().Select().From() + .WhereIn(x => x.NodeId, entityIds)) + .ToDictionary(x => x.NodeId, x => x.NodeObjectType); + + foreach (var e in entities) + { + if (nodes.TryGetValue(e.ParentId, out var parentObjectType)) + { + e.ParentObjectType = parentObjectType.GetValueOrDefault(); + } + if (nodes.TryGetValue(e.ChildId, out var childObjectType)) + { + e.ChildObjectType = childObjectType.GetValueOrDefault(); + } + } + } + + private void ApplyOrdering(ref Sql sql, Ordering ordering) + { + if (sql == null) throw new ArgumentNullException(nameof(sql)); + if (ordering == null) throw new ArgumentNullException(nameof(ordering)); + + // TODO: although this works for name, it probably doesn't work for others without an alias of some sort + var orderBy = ordering.OrderBy; + + if (ordering.Direction == Direction.Ascending) + sql.OrderBy(orderBy); + else + sql.OrderByDescending(orderBy); + } } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/RelationTypeRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/RelationTypeRepository.cs index 075d4aa769..623b55b6f8 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/RelationTypeRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/RelationTypeRepository.cs @@ -134,7 +134,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override void PersistNewItem(IRelationType entity) { entity.AddingEntity(); - + + CheckNullObjectTypeValues(entity); + var dto = RelationTypeFactory.BuildDto(entity); var id = Convert.ToInt32(Database.Insert(dto)); @@ -146,7 +148,9 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override void PersistUpdatedItem(IRelationType entity) { entity.UpdatingEntity(); - + + CheckNullObjectTypeValues(entity); + var dto = RelationTypeFactory.BuildDto(entity); Database.Update(dto); @@ -154,5 +158,13 @@ namespace Umbraco.Core.Persistence.Repositories.Implement } #endregion + + private void CheckNullObjectTypeValues(IRelationType entity) + { + if (entity.ParentObjectType.HasValue && entity.ParentObjectType == Guid.Empty) + entity.ParentObjectType = null; + if (entity.ChildObjectType.HasValue && entity.ChildObjectType == Guid.Empty) + entity.ChildObjectType = null; + } } } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/SimpleGetRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/SimpleGetRepository.cs index 43233d0f31..f7e59820c3 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/SimpleGetRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/SimpleGetRepository.cs @@ -75,19 +75,19 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected sealed override IEnumerable GetDeleteClauses() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } - protected sealed override Guid NodeObjectTypeId => throw new WontImplementException(); + protected sealed override Guid NodeObjectTypeId => throw new InvalidOperationException("This property won't be implemented."); protected sealed override void PersistNewItem(TEntity entity) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected sealed override void PersistUpdatedItem(TEntity entity) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } #endregion diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/UserGroupRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/UserGroupRepository.cs index 0701a0996e..0a66e29147 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/UserGroupRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/UserGroupRepository.cs @@ -286,7 +286,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement return list; } - protected override Guid NodeObjectTypeId => throw new WontImplementException(); + protected override Guid NodeObjectTypeId => throw new InvalidOperationException("This property won't be implemented."); protected override void PersistNewItem(IUserGroup entity) { @@ -370,35 +370,35 @@ namespace Umbraco.Core.Persistence.Repositories.Implement protected override UserGroupWithUsers PerformGet(int id) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override IEnumerable PerformGetAll(params int[] ids) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override IEnumerable PerformGetByQuery(IQuery query) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override Sql GetBaseQuery(bool isCount) { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override string GetBaseWhereClause() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } protected override IEnumerable GetDeleteClauses() { - throw new WontImplementException(); + throw new InvalidOperationException("This method won't be implemented."); } - protected override Guid NodeObjectTypeId => throw new WontImplementException(); + protected override Guid NodeObjectTypeId => throw new InvalidOperationException("This property won't be implemented."); #endregion diff --git a/src/Umbraco.Core/Persistence/SqlSyntax/ISqlSyntaxProvider.cs b/src/Umbraco.Core/Persistence/SqlSyntax/ISqlSyntaxProvider.cs index 55625ff04e..7ae001bf24 100644 --- a/src/Umbraco.Core/Persistence/SqlSyntax/ISqlSyntaxProvider.cs +++ b/src/Umbraco.Core/Persistence/SqlSyntax/ISqlSyntaxProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data; using System.Text.RegularExpressions; using NPoco; using Umbraco.Core.Persistence.DatabaseAnnotations; @@ -76,6 +77,11 @@ namespace Umbraco.Core.Persistence.SqlSyntax string ConvertIntegerToOrderableString { get; } string ConvertDateToOrderableString { get; } string ConvertDecimalToOrderableString { get; } + + /// + /// Returns the default isolation level for the database + /// + IsolationLevel DefaultIsolationLevel { get; } IEnumerable GetTablesInSchema(IDatabase db); IEnumerable GetColumnsInSchema(IDatabase db); @@ -121,5 +127,8 @@ namespace Umbraco.Core.Persistence.SqlSyntax /// unspecified. /// bool TryGetDefaultConstraint(IDatabase db, string tableName, string columnName, out string constraintName); + + void ReadLock(IDatabase db, params int[] lockIds); + void WriteLock(IDatabase db, params int[] lockIds); } } diff --git a/src/Umbraco.Core/Persistence/SqlSyntax/SqlCeSyntaxProvider.cs b/src/Umbraco.Core/Persistence/SqlSyntax/SqlCeSyntaxProvider.cs index cb4b7a5176..2ed0fb878c 100644 --- a/src/Umbraco.Core/Persistence/SqlSyntax/SqlCeSyntaxProvider.cs +++ b/src/Umbraco.Core/Persistence/SqlSyntax/SqlCeSyntaxProvider.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Data; +using System.Data.SqlServerCe; using System.Linq; using NPoco; using Umbraco.Core.Persistence.DatabaseAnnotations; @@ -52,6 +54,8 @@ namespace Umbraco.Core.Persistence.SqlSyntax return "(" + string.Join("+", args) + ")"; } + public override System.Data.IsolationLevel DefaultIsolationLevel => System.Data.IsolationLevel.RepeatableRead; + public override string FormatColumnRename(string tableName, string oldName, string newName) { //NOTE Sql CE doesn't support renaming a column, so a new column needs to be created, then copy data and finally remove old column @@ -152,6 +156,39 @@ where table_name=@0 and column_name=@1", tableName, columnName).FirstOrDefault() return result > 0; } + public override void WriteLock(IDatabase db, params int[] lockIds) + { + // soon as we get Database, a transaction is started + + if (db.Transaction.IsolationLevel < IsolationLevel.RepeatableRead) + throw new InvalidOperationException("A transaction with minimum RepeatableRead isolation level is required."); + + db.Execute(@"SET LOCK_TIMEOUT 1800;"); + // *not* using a unique 'WHERE IN' query here because the *order* of lockIds is important to avoid deadlocks + foreach (var lockId in lockIds) + { + var i = db.Execute(@"UPDATE umbracoLock SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id", new { id = lockId }); + if (i == 0) // ensure we are actually locking! + throw new ArgumentException($"LockObject with id={lockId} does not exist."); + } + } + + public override void ReadLock(IDatabase db, params int[] lockIds) + { + // soon as we get Database, a transaction is started + + if (db.Transaction.IsolationLevel < IsolationLevel.RepeatableRead) + throw new InvalidOperationException("A transaction with minimum RepeatableRead isolation level is required."); + + // *not* using a unique 'WHERE IN' query here because the *order* of lockIds is important to avoid deadlocks + foreach (var lockId in lockIds) + { + var i = db.ExecuteScalar("SELECT value FROM umbracoLock WHERE id=@id", new { id = lockId }); + if (i == null) // ensure we are actually locking! + throw new ArgumentException($"LockObject with id={lockId} does not exist."); + } + } + protected override string FormatIdentity(ColumnDefinition column) { return column.IsIdentity ? GetIdentityString(column) : string.Empty; diff --git a/src/Umbraco.Core/Persistence/SqlSyntax/SqlServerSyntaxProvider.cs b/src/Umbraco.Core/Persistence/SqlSyntax/SqlServerSyntaxProvider.cs index fab7526a6b..bb50fa98a1 100644 --- a/src/Umbraco.Core/Persistence/SqlSyntax/SqlServerSyntaxProvider.cs +++ b/src/Umbraco.Core/Persistence/SqlSyntax/SqlServerSyntaxProvider.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Data; using System.Data.Common; +using System.Data.SqlClient; using System.Linq; using NPoco; using Umbraco.Core.Logging; @@ -179,6 +180,8 @@ namespace Umbraco.Core.Persistence.SqlSyntax return items.Select(x => x.TABLE_NAME).Cast().ToList(); } + public override IsolationLevel DefaultIsolationLevel => IsolationLevel.ReadCommitted; + public override IEnumerable GetColumnsInSchema(IDatabase db) { var items = db.Fetch("SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = (SELECT SCHEMA_NAME())"); @@ -246,6 +249,46 @@ where tbl.[name]=@0 and col.[name]=@1;", tableName, columnName) return result > 0; } + public override void WriteLock(IDatabase db, params int[] lockIds) + { + WriteLock(db, TimeSpan.FromMilliseconds(1800), lockIds); + } + + public void WriteLock(IDatabase db, TimeSpan timeout, params int[] lockIds) + { + // soon as we get Database, a transaction is started + + if (db.Transaction.IsolationLevel < IsolationLevel.ReadCommitted) + throw new InvalidOperationException("A transaction with minimum ReadCommitted isolation level is required."); + + + // *not* using a unique 'WHERE IN' query here because the *order* of lockIds is important to avoid deadlocks + foreach (var lockId in lockIds) + { + db.Execute($"SET LOCK_TIMEOUT {timeout.TotalMilliseconds};"); + var i = db.Execute(@"UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id", new { id = lockId }); + if (i == 0) // ensure we are actually locking! + throw new ArgumentException($"LockObject with id={lockId} does not exist."); + } + } + + + public override void ReadLock(IDatabase db, params int[] lockIds) + { + // soon as we get Database, a transaction is started + + if (db.Transaction.IsolationLevel < IsolationLevel.ReadCommitted) + throw new InvalidOperationException("A transaction with minimum ReadCommitted isolation level is required."); + + // *not* using a unique 'WHERE IN' query here because the *order* of lockIds is important to avoid deadlocks + foreach (var lockId in lockIds) + { + var i = db.ExecuteScalar("SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id", new { id = lockId }); + if (i == null) // ensure we are actually locking! + throw new ArgumentException($"LockObject with id={lockId} does not exist.", nameof(lockIds)); + } + } + public override string FormatColumnRename(string tableName, string oldName, string newName) { return string.Format(RenameColumn, tableName, oldName, newName); diff --git a/src/Umbraco.Core/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs b/src/Umbraco.Core/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs index 0c27ac2d50..b2e03df96e 100644 --- a/src/Umbraco.Core/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs +++ b/src/Umbraco.Core/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs @@ -200,7 +200,9 @@ namespace Umbraco.Core.Persistence.SqlSyntax return "NVARCHAR"; } - + + public abstract IsolationLevel DefaultIsolationLevel { get; } + public virtual IEnumerable GetTablesInSchema(IDatabase db) { return new List(); @@ -225,6 +227,9 @@ namespace Umbraco.Core.Persistence.SqlSyntax public abstract bool TryGetDefaultConstraint(IDatabase db, string tableName, string columnName, out string constraintName); + public abstract void ReadLock(IDatabase db, params int[] lockIds); + public abstract void WriteLock(IDatabase db, params int[] lockIds); + public virtual bool DoesTableExist(IDatabase db, string tableName) { return false; diff --git a/src/Umbraco.Core/Persistence/SqlSyntax/SqlSyntaxProviderExtensions.cs b/src/Umbraco.Core/Persistence/SqlSyntax/SqlSyntaxProviderExtensions.cs index f7cf480830..b829f1fbc5 100644 --- a/src/Umbraco.Core/Persistence/SqlSyntax/SqlSyntaxProviderExtensions.cs +++ b/src/Umbraco.Core/Persistence/SqlSyntax/SqlSyntaxProviderExtensions.cs @@ -35,6 +35,8 @@ namespace Umbraco.Core.Persistence.SqlSyntax /// public static Sql GetDeleteSubquery(this ISqlSyntaxProvider sqlProvider, string tableName, string columnName, Sql subQuery, WhereInType whereInType = WhereInType.In) { + //TODO: This is no longer necessary since this used to be a specific requirement for MySql! + // Now we can do a Delete + sub query, see RelationRepository.DeleteByParent for example return new Sql(string.Format( diff --git a/src/Umbraco.Core/Persistence/UmbracoDatabase.cs b/src/Umbraco.Core/Persistence/UmbracoDatabase.cs index 51e0172f35..a95d95ea08 100644 --- a/src/Umbraco.Core/Persistence/UmbracoDatabase.cs +++ b/src/Umbraco.Core/Persistence/UmbracoDatabase.cs @@ -20,9 +20,6 @@ namespace Umbraco.Core.Persistence /// public class UmbracoDatabase : Database, IUmbracoDatabase { - // Umbraco's default isolation level is RepeatableRead - private const IsolationLevel DefaultIsolationLevel = IsolationLevel.RepeatableRead; - private readonly ILogger _logger; private readonly RetryPolicy _connectionRetryPolicy; private readonly RetryPolicy _commandRetryPolicy; @@ -38,7 +35,7 @@ namespace Umbraco.Core.Persistence /// Also used by DatabaseBuilder for creating databases and installing/upgrading. /// public UmbracoDatabase(string connectionString, ISqlContext sqlContext, DbProviderFactory provider, ILogger logger, RetryPolicy connectionRetryPolicy = null, RetryPolicy commandRetryPolicy = null) - : base(connectionString, sqlContext.DatabaseType, provider, DefaultIsolationLevel) + : base(connectionString, sqlContext.DatabaseType, provider, sqlContext.SqlSyntax.DefaultIsolationLevel) { SqlContext = sqlContext; @@ -47,6 +44,8 @@ namespace Umbraco.Core.Persistence _commandRetryPolicy = commandRetryPolicy; EnableSqlTrace = EnableSqlTraceDefault; + + NPocoDatabaseExtensions.ConfigureNPocoBulkExtensions(); } /// @@ -54,12 +53,14 @@ namespace Umbraco.Core.Persistence /// /// Internal for unit tests only. internal UmbracoDatabase(DbConnection connection, ISqlContext sqlContext, ILogger logger) - : base(connection, sqlContext.DatabaseType, DefaultIsolationLevel) + : base(connection, sqlContext.DatabaseType, sqlContext.SqlSyntax.DefaultIsolationLevel) { SqlContext = sqlContext; _logger = logger; EnableSqlTrace = EnableSqlTraceDefault; + + NPocoDatabaseExtensions.ConfigureNPocoBulkExtensions(); } #endregion diff --git a/src/Umbraco.Core/Persistence/UmbracoDatabaseFactory.cs b/src/Umbraco.Core/Persistence/UmbracoDatabaseFactory.cs index 13422f43b1..c502abc87c 100644 --- a/src/Umbraco.Core/Persistence/UmbracoDatabaseFactory.cs +++ b/src/Umbraco.Core/Persistence/UmbracoDatabaseFactory.cs @@ -4,7 +4,6 @@ using System.Data.Common; using System.Threading; using NPoco; using NPoco.FluentMappings; -using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Persistence.FaultHandling; using Umbraco.Core.Persistence.Mappers; @@ -61,8 +60,8 @@ namespace Umbraco.Core.Persistence /// Used by the other ctor and in tests. public UmbracoDatabaseFactory(string connectionStringName, ILogger logger, Lazy mappers) { - if (string.IsNullOrWhiteSpace(connectionStringName)) - throw new ArgumentNullOrEmptyException(nameof(connectionStringName)); + if (connectionStringName == null) throw new ArgumentNullException(nameof(connectionStringName)); + if (string.IsNullOrWhiteSpace(connectionStringName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(connectionStringName)); _mappers = mappers ?? throw new ArgumentNullException(nameof(mappers)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); diff --git a/src/Umbraco.Core/Properties/AssemblyInfo.cs b/src/Umbraco.Core/Properties/AssemblyInfo.cs index 9471285148..87e0732d47 100644 --- a/src/Umbraco.Core/Properties/AssemblyInfo.cs +++ b/src/Umbraco.Core/Properties/AssemblyInfo.cs @@ -14,6 +14,7 @@ using System.Runtime.InteropServices; [assembly: InternalsVisibleTo("Umbraco.Web")] [assembly: InternalsVisibleTo("Umbraco.Web.UI")] [assembly: InternalsVisibleTo("Umbraco.Examine")] +[assembly: InternalsVisibleTo("Umbraco.ModelsBuilder.Embedded")] [assembly: InternalsVisibleTo("Umbraco.Tests")] [assembly: InternalsVisibleTo("Umbraco.Tests.Benchmarks")] @@ -32,7 +33,7 @@ using System.Runtime.InteropServices; [assembly: InternalsVisibleTo("Umbraco.Forms.Web")] // Umbraco Headless -[assembly: InternalsVisibleTo("Umbraco.Headless")] +[assembly: InternalsVisibleTo("Umbraco.Cloud.Headless")] // code analysis // IDE1006 is broken, wants _value syntax for consts, etc - and it's even confusing ppl at MS, kill it diff --git a/src/Umbraco.Core/PropertyEditors/ConfigurationFieldAttribute.cs b/src/Umbraco.Core/PropertyEditors/ConfigurationFieldAttribute.cs index d90aeef2e6..1852c742f0 100644 --- a/src/Umbraco.Core/PropertyEditors/ConfigurationFieldAttribute.cs +++ b/src/Umbraco.Core/PropertyEditors/ConfigurationFieldAttribute.cs @@ -1,5 +1,4 @@ using System; -using Umbraco.Core.Exceptions; namespace Umbraco.Core.PropertyEditors { @@ -28,13 +27,15 @@ namespace Umbraco.Core.PropertyEditors /// The view to use to render the field editor. public ConfigurationFieldAttribute(string key, string name, string view) { - if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullOrEmptyException(nameof(key)); + if (key == null) throw new ArgumentNullException(nameof(key)); + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(key)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); + if (view == null) throw new ArgumentNullException(nameof(view)); + if (string.IsNullOrWhiteSpace(view)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(view)); + Key = key; - - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); Name = name; - - if (string.IsNullOrWhiteSpace(view)) throw new ArgumentNullOrEmptyException(nameof(view)); View = view; } @@ -47,10 +48,12 @@ namespace Umbraco.Core.PropertyEditors /// from the name of the property marked with this attribute. public ConfigurationFieldAttribute(string name, string view) { - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); - Name = name; + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); + if (view == null) throw new ArgumentNullException(nameof(view)); + if (string.IsNullOrWhiteSpace(view)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(view)); - if (string.IsNullOrWhiteSpace(view)) throw new ArgumentNullOrEmptyException(nameof(view)); + Name = name; View = view; } diff --git a/src/Umbraco.Core/PropertyEditors/ConfigurationFieldsExtensions.cs b/src/Umbraco.Core/PropertyEditors/ConfigurationFieldsExtensions.cs new file mode 100644 index 0000000000..25fba622d5 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ConfigurationFieldsExtensions.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Umbraco.Core.PropertyEditors +{ + public static partial class ConfigurationFieldsExtensions + { + /// + /// Adds a configuration field. + /// + /// The list of configuration fields. + /// The key (alias) of the field. + /// The name (label) of the field. + /// The description for the field. + /// The path to the editor view to be used for the field. + /// Optional configuration used for field's editor. + public static void Add( + this List fields, + string key, + string name, + string description, + string view, + IDictionary config = null) + { + fields.Add(new ConfigurationField + { + Key = key, + Name = name, + Description = description, + View = view, + Config = config, + }); + } + } +} diff --git a/src/Umbraco.Core/PropertyEditors/DataEditor.cs b/src/Umbraco.Core/PropertyEditors/DataEditor.cs index 43f4b68b99..7dc260e4c7 100644 --- a/src/Umbraco.Core/PropertyEditors/DataEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataEditor.cs @@ -19,6 +19,7 @@ namespace Umbraco.Core.PropertyEditors public class DataEditor : IDataEditor { private IDictionary _defaultConfiguration; + private IDataValueEditor _dataValueEditor; /// /// Initializes a new instance of the class. @@ -30,7 +31,7 @@ namespace Umbraco.Core.PropertyEditors // defaults Type = type; Icon = Constants.Icons.PropertyEditor; - Group = "common"; + Group = Constants.PropertyEditors.Groups.Common; // assign properties based on the attribute, if it is found Attribute = GetType().GetCustomAttribute(false); @@ -90,7 +91,7 @@ namespace Umbraco.Core.PropertyEditors /// simple enough for now. /// // TODO: point of that one? shouldn't we always configure? - public IDataValueEditor GetValueEditor() => ExplicitValueEditor ?? CreateValueEditor(); + public IDataValueEditor GetValueEditor() => ExplicitValueEditor ?? (_dataValueEditor ?? (_dataValueEditor = CreateValueEditor())); /// /// @@ -113,7 +114,7 @@ namespace Umbraco.Core.PropertyEditors return ExplicitValueEditor; var editor = CreateValueEditor(); - ((DataValueEditor) editor).Configuration = configuration; // TODO: casting is bad + ((DataValueEditor)editor).Configuration = configuration; // TODO: casting is bad return editor; } @@ -163,7 +164,7 @@ namespace Umbraco.Core.PropertyEditors protected virtual IDataValueEditor CreateValueEditor() { if (Attribute == null) - throw new InvalidOperationException("The editor does not specify a view."); + throw new InvalidOperationException($"The editor is not attributed with {nameof(DataEditorAttribute)}"); return new DataValueEditor(Attribute); } @@ -175,7 +176,7 @@ namespace Umbraco.Core.PropertyEditors { var editor = new ConfigurationEditor(); // pass the default configuration if this is not a property value editor - if((Type & EditorType.PropertyValue) == 0) + if ((Type & EditorType.PropertyValue) == 0) { editor.DefaultConfiguration = _defaultConfiguration; } diff --git a/src/Umbraco.Core/PropertyEditors/DataEditorAttribute.cs b/src/Umbraco.Core/PropertyEditors/DataEditorAttribute.cs index ca08127d51..7b3be7ea5f 100644 --- a/src/Umbraco.Core/PropertyEditors/DataEditorAttribute.cs +++ b/src/Umbraco.Core/PropertyEditors/DataEditorAttribute.cs @@ -1,5 +1,4 @@ using System; -using Umbraco.Core.Exceptions; namespace Umbraco.Core.PropertyEditors { @@ -53,17 +52,17 @@ namespace Umbraco.Core.PropertyEditors /// public DataEditorAttribute(string alias, EditorType type, string name, string view) { - if ((type & ~(EditorType.PropertyValue | EditorType.MacroParameter)) > 0) - throw new ArgumentOutOfRangeException(nameof(type), $"Not a valid {typeof(EditorType)} value."); + if (alias == null) throw new ArgumentNullException(nameof(alias)); + if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(alias)); + if ((type & ~(EditorType.PropertyValue | EditorType.MacroParameter)) > 0) throw new ArgumentOutOfRangeException(nameof(type), type, $"Not a valid {typeof(EditorType)} value."); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); + if (view == null) throw new ArgumentNullException(nameof(view)); + if (string.IsNullOrWhiteSpace(view)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(view)); + Type = type; - - if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentNullOrEmptyException(nameof(alias)); Alias = alias; - - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); Name = name; - - if (string.IsNullOrWhiteSpace(view)) throw new ArgumentNullOrEmptyException(nameof(view)); View = view == NullView ? null : view; } @@ -100,8 +99,10 @@ namespace Umbraco.Core.PropertyEditors get => _valueType; set { - if (string.IsNullOrWhiteSpace(value)) throw new ArgumentNullOrEmptyException(nameof(value)); - if (!ValueTypes.IsValue(value)) throw new ArgumentOutOfRangeException(nameof(value), $"Not a valid {typeof(ValueTypes)} value."); + if (value == null) throw new ArgumentNullException(nameof(value)); + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(value)); + if (!ValueTypes.IsValue(value)) throw new ArgumentOutOfRangeException(nameof(value), value, $"Not a valid {typeof(ValueTypes)} value."); + _valueType = value; } } @@ -121,7 +122,7 @@ namespace Umbraco.Core.PropertyEditors /// Gets or sets an optional group. /// /// The group can be used for example to group the editors by category. - public string Group { get; set; } = "common"; + public string Group { get; set; } = Constants.PropertyEditors.Groups.Common; /// /// Gets or sets a value indicating whether the value editor is deprecated. diff --git a/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs b/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs new file mode 100644 index 0000000000..386ab6a8f3 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using Umbraco.Core.Composing; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Editors; + +namespace Umbraco.Core.PropertyEditors +{ + public class DataValueReferenceFactoryCollection : BuilderCollectionBase + { + public DataValueReferenceFactoryCollection(IEnumerable items) + : base(items) + { } + + public IEnumerable GetAllReferences(PropertyCollection properties, PropertyEditorCollection propertyEditors) + { + var trackedRelations = new List(); + + foreach (var p in properties) + { + if (!propertyEditors.TryGet(p.PropertyType.PropertyEditorAlias, out var editor)) continue; + + //TODO: Support variants/segments! This is not required for this initial prototype which is why there is a check here + if (!p.PropertyType.VariesByNothing()) continue; + var val = p.GetValue(); // get the invariant value + + var valueEditor = editor.GetValueEditor(); + if (valueEditor is IDataValueReference reference) + { + var refs = reference.GetReferences(val); + trackedRelations.AddRange(refs); + } + + // Loop over collection that may be add to existing property editors + // implementation of GetReferences in IDataValueReference. + // Allows developers to add support for references by a + // package /property editor that did not implement IDataValueReference themselves + foreach (var item in this) + { + // Check if this value reference is for this datatype/editor + // Then call it's GetReferences method - to see if the value stored + // in the dataeditor/property has referecnes to media/content items + if (item.IsForEditor(editor)) + trackedRelations.AddRange(item.GetDataValueReference().GetReferences(val)); + } + } + + return trackedRelations; + } + } +} diff --git a/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollectionBuilder.cs b/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollectionBuilder.cs new file mode 100644 index 0000000000..2cf76712c8 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollectionBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Core.Composing; + +namespace Umbraco.Core.PropertyEditors +{ + public class DataValueReferenceFactoryCollectionBuilder : OrderedCollectionBuilderBase + { + protected override DataValueReferenceFactoryCollectionBuilder This => this; + } +} diff --git a/src/Umbraco.Core/PropertyEditors/GridEditor.cs b/src/Umbraco.Core/PropertyEditors/GridEditor.cs index 986eed9ccc..cc3561fbc2 100644 --- a/src/Umbraco.Core/PropertyEditors/GridEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/GridEditor.cs @@ -18,6 +18,9 @@ namespace Umbraco.Core.PropertyEditors [JsonProperty("name", Required = Required.Always)] public string Name { get; set; } + [JsonProperty("nameTemplate")] + public string NameTemplate { get; set; } + [JsonProperty("alias", Required = Required.Always)] public string Alias { get; set; } diff --git a/src/Umbraco.Core/PropertyEditors/IDataEditorWithMediaPath.cs b/src/Umbraco.Core/PropertyEditors/IDataEditorWithMediaPath.cs new file mode 100644 index 0000000000..e8af1b0ac3 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IDataEditorWithMediaPath.cs @@ -0,0 +1,19 @@ +namespace Umbraco.Core.PropertyEditors +{ + /// + /// Must be implemented by property editors that store media and return media paths + /// + /// + /// Currently there are only 2x core editors that do this: upload and image cropper. + /// It would be possible for developers to know implement their own media property editors whereas previously this was not possible. + /// + public interface IDataEditorWithMediaPath + { + /// + /// Returns the media path for the value stored for a property + /// + /// + /// + string GetMediaPath(object value); + } +} diff --git a/src/Umbraco.Core/PropertyEditors/IDataValueEditor.cs b/src/Umbraco.Core/PropertyEditors/IDataValueEditor.cs index cb68531cc7..a02fa71ec7 100644 --- a/src/Umbraco.Core/PropertyEditors/IDataValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/IDataValueEditor.cs @@ -7,6 +7,7 @@ using Umbraco.Core.Services; namespace Umbraco.Core.PropertyEditors { + /// /// Represents an editor for editing data values. /// @@ -63,8 +64,26 @@ namespace Umbraco.Core.PropertyEditors // TODO: / deal with this when unplugging the xml cache // why property vs propertyType? services should be injected! etc... + + /// + /// Used for serializing an item for packaging + /// + /// + /// + /// + /// + /// IEnumerable ConvertDbToXml(Property property, IDataTypeService dataTypeService, ILocalizationService localizationService, bool published); + + /// + /// Used for serializing an item for packaging + /// + /// + /// + /// + /// XNode ConvertDbToXml(PropertyType propertyType, object value, IDataTypeService dataTypeService); + string ConvertDbToString(PropertyType propertyType, object value, IDataTypeService dataTypeService); } } diff --git a/src/Umbraco.Core/PropertyEditors/IDataValueReference.cs b/src/Umbraco.Core/PropertyEditors/IDataValueReference.cs new file mode 100644 index 0000000000..6377098bfc --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IDataValueReference.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Umbraco.Core.Models.Editors; + +namespace Umbraco.Core.PropertyEditors +{ + /// + /// Resolve references from values + /// + public interface IDataValueReference + { + /// + /// Returns any references contained in the value + /// + /// + /// + IEnumerable GetReferences(object value); + } +} diff --git a/src/Umbraco.Core/PropertyEditors/IDataValueReferenceFactory.cs b/src/Umbraco.Core/PropertyEditors/IDataValueReferenceFactory.cs new file mode 100644 index 0000000000..6587e071bf --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IDataValueReferenceFactory.cs @@ -0,0 +1,18 @@ +namespace Umbraco.Core.PropertyEditors +{ + public interface IDataValueReferenceFactory + { + /// + /// Gets a value indicating whether the DataValueReference lookup supports a datatype (data editor). + /// + /// The datatype. + /// A value indicating whether the converter supports a datatype. + bool IsForEditor(IDataEditor dataEditor); + + /// + /// + /// + /// + IDataValueReference GetDataValueReference(); + } +} diff --git a/src/Umbraco.Core/PropertyEditors/IValueFormatValidator.cs b/src/Umbraco.Core/PropertyEditors/IValueFormatValidator.cs index 3e4aea4385..9f29d65ffd 100644 --- a/src/Umbraco.Core/PropertyEditors/IValueFormatValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/IValueFormatValidator.cs @@ -21,4 +21,4 @@ namespace Umbraco.Core.PropertyEditors /// IEnumerable ValidateFormat(object value, string valueType, string format); } -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/PropertyEditors/IValueRequiredValidator.cs b/src/Umbraco.Core/PropertyEditors/IValueRequiredValidator.cs index f8e62788c8..1b4074c96f 100644 --- a/src/Umbraco.Core/PropertyEditors/IValueRequiredValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/IValueRequiredValidator.cs @@ -19,4 +19,4 @@ namespace Umbraco.Core.PropertyEditors /// IEnumerable ValidateRequired(object value, string valueType); } -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/PropertyEditors/LabelPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/LabelPropertyEditor.cs index d0c40b1e63..60b7d55c01 100644 --- a/src/Umbraco.Core/PropertyEditors/LabelPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/LabelPropertyEditor.cs @@ -5,7 +5,11 @@ namespace Umbraco.Core.PropertyEditors /// /// Represents a property editor for label properties. /// - [DataEditor(Constants.PropertyEditors.Aliases.Label, "Label", "readonlyvalue", Icon = "icon-readonly")] + [DataEditor( + Constants.PropertyEditors.Aliases.Label, + "Label", + "readonlyvalue", + Icon = "icon-readonly")] public class LabelPropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Core/PropertyEditors/PropertyEditorCollection.cs b/src/Umbraco.Core/PropertyEditors/PropertyEditorCollection.cs index 712a66e55d..21854b63c1 100644 --- a/src/Umbraco.Core/PropertyEditors/PropertyEditorCollection.cs +++ b/src/Umbraco.Core/PropertyEditors/PropertyEditorCollection.cs @@ -4,6 +4,8 @@ using Umbraco.Core.Manifest; namespace Umbraco.Core.PropertyEditors { + + public class PropertyEditorCollection : BuilderCollectionBase { public PropertyEditorCollection(DataEditorCollection dataEditors, ManifestParser manifestParser) @@ -27,4 +29,4 @@ namespace Umbraco.Core.PropertyEditors return editor != null; } } -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/PropertyEditors/TagsPropertyEditorAttribute.cs b/src/Umbraco.Core/PropertyEditors/TagsPropertyEditorAttribute.cs index 2439c7c02e..6549f1a233 100644 --- a/src/Umbraco.Core/PropertyEditors/TagsPropertyEditorAttribute.cs +++ b/src/Umbraco.Core/PropertyEditors/TagsPropertyEditorAttribute.cs @@ -55,6 +55,7 @@ namespace Umbraco.Core.PropertyEditors /// /// Gets the type of the dynamic configuration provider. /// + //TODO: This is not used and should be implemented in a nicer way, see https://github.com/umbraco/Umbraco-CMS/issues/6017#issuecomment-516253562 public Type TagsConfigurationProviderType { get; } } } diff --git a/src/Umbraco.Core/PropertyEditors/Validators/RegexValidator.cs b/src/Umbraco.Core/PropertyEditors/Validators/RegexValidator.cs index e405fa3a3e..8e82d694a7 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/RegexValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/RegexValidator.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; using Umbraco.Core.Composing; -using Umbraco.Core.Exceptions; using Umbraco.Core.Services; namespace Umbraco.Core.PropertyEditors.Validators @@ -48,8 +47,9 @@ namespace Umbraco.Core.PropertyEditors.Validators get => _regex; set { - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentNullOrEmptyException(nameof(value)); + if (value == null) throw new ArgumentNullException(nameof(value)); + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(value)); + _regex = value; } } @@ -58,7 +58,9 @@ namespace Umbraco.Core.PropertyEditors.Validators public IEnumerable Validate(object value, string valueType, object dataTypeConfiguration) { if (_regex == null) + { throw new InvalidOperationException("The validator has not been configured."); + } return ValidateFormat(value, valueType, _regex); } @@ -66,9 +68,12 @@ namespace Umbraco.Core.PropertyEditors.Validators /// public IEnumerable ValidateFormat(object value, string valueType, string format) { - if (string.IsNullOrWhiteSpace(format)) throw new ArgumentNullOrEmptyException(nameof(format)); + if (format == null) throw new ArgumentNullException(nameof(format)); + if (string.IsNullOrWhiteSpace(format)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(format)); if (value == null || !new Regex(format).IsMatch(value.ToString())) + { yield return new ValidationResult(_textService.Localize("validation", "invalidPattern"), new[] { "value" }); + } } } } diff --git a/src/Umbraco.Core/PropertyEditors/Validators/RequiredValidator.cs b/src/Umbraco.Core/PropertyEditors/Validators/RequiredValidator.cs index c51f572817..1aa29870e5 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/RequiredValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/RequiredValidator.cs @@ -35,14 +35,17 @@ namespace Umbraco.Core.PropertyEditors.Validators { if (value == null) { - yield return new ValidationResult(_textService.Localize("validation", "invalidNull"), new[] {"value"}); + yield return new ValidationResult(_textService.Localize("validation", "invalidNull"), new[] { "value" }); yield break; } if (valueType.InvariantEquals(ValueTypes.Json)) { if (value.ToString().DetectIsEmptyJson()) + { yield return new ValidationResult(_textService.Localize("validation", "invalidEmpty"), new[] { "value" }); + } + yield break; } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/LabelValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/LabelValueConverter.cs index 84baf226cf..eb461b4920 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/LabelValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/LabelValueConverter.cs @@ -65,6 +65,8 @@ namespace Umbraco.Core.PropertyEditors.ValueConverters if (source is decimal sourceDecimal) return sourceDecimal; if (source is string sourceDecimalString) return decimal.TryParse(sourceDecimalString, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0; + if (source is double sourceDouble) + return Convert.ToDecimal(sourceDouble); return (decimal) 0; case ValueTypes.Integer: if (source is int sourceInt) return sourceInt; diff --git a/src/Umbraco.Core/PublishedModelFactoryExtensions.cs b/src/Umbraco.Core/PublishedModelFactoryExtensions.cs index de6eeb6a42..ac8c9f1be7 100644 --- a/src/Umbraco.Core/PublishedModelFactoryExtensions.cs +++ b/src/Umbraco.Core/PublishedModelFactoryExtensions.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; using Umbraco.Core.Models.PublishedContent; namespace Umbraco.Core @@ -15,12 +17,8 @@ namespace Umbraco.Core /// public static bool IsLiveFactory(this IPublishedModelFactory factory) => factory is ILivePublishedModelFactory; - /// - /// Executes an action with a safe live factory - /// - /// - /// If the factory is a live factory, ensures it is refreshed and locked while executing the action. - /// + [Obsolete("This method is no longer used or necessary and will be removed from future")] + [EditorBrowsable(EditorBrowsableState.Never)] public static void WithSafeLiveFactory(this IPublishedModelFactory factory, Action action) { if (factory is ILivePublishedModelFactory liveFactory) @@ -37,5 +35,38 @@ namespace Umbraco.Core action(); } } + + /// + /// Sets a flag to reset the ModelsBuilder models if the is + /// + /// + /// + /// + /// This does not recompile the pure live models, only sets a flag to tell models builder to recompile when they are requested. + /// + internal static void WithSafeLiveFactoryReset(this IPublishedModelFactory factory, Action action) + { + if (factory is ILivePublishedModelFactory liveFactory) + { + lock (liveFactory.SyncRoot) + { + // TODO: Fix this in 8.3! - We need to change the ILivePublishedModelFactory interface to have a Reset method and then when we have an embedded MB + // version we will publicize the ResetModels (and change the name to Reset). + // For now, this will suffice and we'll use reflection, there should be no other implementation of ILivePublishedModelFactory. + // Calling ResetModels resets the MB flag so that the next time EnsureModels is called (which is called when nucache lazily calls CreateModel) it will + // trigger the recompiling of pure live models. + var resetMethod = liveFactory.GetType().GetMethod("ResetModels", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + if (resetMethod != null) + resetMethod.Invoke(liveFactory, null); + + action(); + } + } + else + { + action(); + } + } + } } diff --git a/src/Umbraco.Core/ReflectionUtilities.cs b/src/Umbraco.Core/ReflectionUtilities.cs index 622d81f5f2..a8e6836ca1 100644 --- a/src/Umbraco.Core/ReflectionUtilities.cs +++ b/src/Umbraco.Core/ReflectionUtilities.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Reflection.Emit; -using Umbraco.Core.Exceptions; namespace Umbraco.Core { @@ -29,10 +28,14 @@ namespace Umbraco.Core /// The declaring type. /// The field type. /// The name of the field. - /// A field getter function. - /// Occurs when is null or empty. - /// Occurs when the field does not exist. - /// Occurs when does not match the type of the field. + /// + /// A field getter function. + /// + /// fieldName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Value type does not match field . type. + /// Could not find field .. public static Func EmitFieldGetter(string fieldName) { var field = GetField(fieldName); @@ -45,10 +48,14 @@ namespace Umbraco.Core /// The declaring type. /// The field type. /// The name of the field. - /// A field setter action. - /// Occurs when is null or empty. - /// Occurs when the field does not exist. - /// Occurs when does not match the type of the field. + /// + /// A field setter action. + /// + /// fieldName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Value type does not match field . type. + /// Could not find field .. public static Action EmitFieldSetter(string fieldName) { var field = GetField(fieldName); @@ -61,19 +68,36 @@ namespace Umbraco.Core /// The declaring type. /// The field type. /// The name of the field. - /// A field getter and setter functions. - /// Occurs when is null or empty. - /// Occurs when the field does not exist. - /// Occurs when does not match the type of the field. + /// + /// A field getter and setter functions. + /// + /// fieldName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Value type does not match field . type. + /// Could not find field .. public static (Func, Action) EmitFieldGetterAndSetter(string fieldName) { var field = GetField(fieldName); return (EmitFieldGetter(field), EmitFieldSetter(field)); } + /// + /// Gets the field. + /// + /// The type of the declaring. + /// The type of the value. + /// Name of the field. + /// + /// fieldName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Value type does not match field . type. + /// Could not find field .. private static FieldInfo GetField(string fieldName) { - if (string.IsNullOrWhiteSpace(fieldName)) throw new ArgumentNullOrEmptyException(nameof(fieldName)); + if (fieldName == null) throw new ArgumentNullException(nameof(fieldName)); + if (string.IsNullOrWhiteSpace(fieldName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(fieldName)); // get the field var field = typeof(TDeclaring).GetField(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); @@ -120,13 +144,18 @@ namespace Umbraco.Core /// The property type. /// The name of the property. /// A value indicating whether the property and its getter must exist. - /// A property getter function. If is false, returns null when the property or its getter does not exist. - /// Occurs when is null or empty. - /// Occurs when the property or its getter does not exist. - /// Occurs when does not match the type of the property. + /// + /// A property getter function. If is false, returns null when the property or its getter does not exist. + /// + /// propertyName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Value type does not match property . type. + /// Could not find property getter for .. public static Func EmitPropertyGetter(string propertyName, bool mustExist = true) { - if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentNullOrEmptyException(nameof(propertyName)); + if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); + if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(propertyName)); var property = typeof(TDeclaring).GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); @@ -146,13 +175,18 @@ namespace Umbraco.Core /// The property type. /// The name of the property. /// A value indicating whether the property and its setter must exist. - /// A property setter function. If is false, returns null when the property or its setter does not exist. - /// Occurs when is null or empty. - /// Occurs when the property or its setter does not exist. - /// Occurs when does not match the type of the property. + /// + /// A property setter function. If is false, returns null when the property or its setter does not exist. + /// + /// propertyName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Value type does not match property . type. + /// Could not find property setter for .. public static Action EmitPropertySetter(string propertyName, bool mustExist = true) { - if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentNullOrEmptyException(nameof(propertyName)); + if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); + if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(propertyName)); var property = typeof(TDeclaring).GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); @@ -172,13 +206,18 @@ namespace Umbraco.Core /// The property type. /// The name of the property. /// A value indicating whether the property and its getter and setter must exist. - /// A property getter and setter functions. If is false, returns null when the property or its getter or setter does not exist. - /// Occurs when is null or empty. - /// Occurs when the property or its getter or setter does not exist. - /// Occurs when does not match the type of the property. + /// + /// A property getter and setter functions. If is false, returns null when the property or its getter or setter does not exist. + /// + /// propertyName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Value type does not match property . type. + /// Could not find property getter and setter for .. public static (Func, Action) EmitPropertyGetterAndSetter(string propertyName, bool mustExist = true) { - if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentNullOrEmptyException(nameof(propertyName)); + if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); + if (string.IsNullOrWhiteSpace(propertyName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(propertyName)); var property = typeof(TDeclaring).GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); @@ -402,13 +441,17 @@ namespace Umbraco.Core /// A lambda representing the method. /// The name of the method. /// A value indicating whether the constructor must exist. - /// The method. If is false, returns null when the method does not exist. + /// + /// The method. If is false, returns null when the method does not exist. + /// + /// methodName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Occurs when does not match the method signature.. + /// Occurs when no proper method with name could be found. /// - /// The method arguments are determined by generic arguments. + /// The method arguments are determined by generic arguments. /// - /// Occurs when is null or empty. - /// Occurs when no proper method with name could be found. - /// Occurs when Occurs when does not match the method signature. public static TLambda EmitMethod(string methodName, bool mustExist = true) { return EmitMethod(typeof(TDeclaring), methodName, mustExist); @@ -421,16 +464,21 @@ namespace Umbraco.Core /// The declaring type. /// The name of the method. /// A value indicating whether the constructor must exist. - /// The method. If is false, returns null when the method does not exist. + /// + /// The method. If is false, returns null when the method does not exist. + /// + /// methodName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Occurs when does not match the method signature.. + /// Occurs when no proper method with name could be found. /// - /// The method arguments are determined by generic arguments. + /// The method arguments are determined by generic arguments. /// - /// Occurs when is null or empty. - /// Occurs when no proper method with name could be found. - /// Occurs when Occurs when does not match the method signature. public static TLambda EmitMethod(Type declaring, string methodName, bool mustExist = true) { - if (string.IsNullOrWhiteSpace(methodName)) throw new ArgumentNullOrEmptyException(nameof(methodName)); + if (methodName == null) throw new ArgumentNullException(nameof(methodName)); + if (string.IsNullOrWhiteSpace(methodName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(methodName)); var (lambdaDeclaring, lambdaParameters, lambdaReturned) = AnalyzeLambda(true, out var isFunction); @@ -510,17 +558,21 @@ namespace Umbraco.Core /// A lambda representing the method. /// The name of the method. /// A value indicating whether the constructor must exist. - /// The method. If is false, returns null when the method does not exist. + /// + /// The method. If is false, returns null when the method does not exist. + /// + /// methodName + /// Value can't be empty or consist only of white-space characters. - + /// or + /// Occurs when does not match the method signature.. + /// Occurs when no proper method with name could be found. /// - /// The method arguments are determined by generic arguments. + /// The method arguments are determined by generic arguments. /// - /// Occurs when is null or empty. - /// Occurs when no proper method with name could be found. - /// Occurs when Occurs when does not match the method signature. public static TLambda EmitMethod(string methodName, bool mustExist = true) { - if (string.IsNullOrWhiteSpace(methodName)) - throw new ArgumentNullOrEmptyException(nameof(methodName)); + if (methodName == null) throw new ArgumentNullException(nameof(methodName)); + if (string.IsNullOrWhiteSpace(methodName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(methodName)); // validate lambda type var (lambdaDeclaring, lambdaParameters, lambdaReturned) = AnalyzeLambda(false, out var isFunction); diff --git a/src/Umbraco.Core/Runtime/CoreInitialComposer.cs b/src/Umbraco.Core/Runtime/CoreInitialComposer.cs index 1f004846d0..d95ada499b 100644 --- a/src/Umbraco.Core/Runtime/CoreInitialComposer.cs +++ b/src/Umbraco.Core/Runtime/CoreInitialComposer.cs @@ -43,7 +43,7 @@ namespace Umbraco.Core.Runtime // register persistence mappers - required by database factory so needs to be done here // means the only place the collection can be modified is in a runtime - afterwards it // has been frozen and it is too late - composition.WithCollectionBuilder().AddCoreMappers(); + composition.Mappers().AddCoreMappers(); // register the scope provider composition.RegisterUnique(); // implements both IScopeProvider and IScopeAccessor @@ -70,11 +70,15 @@ namespace Umbraco.Core.Runtime composition.ManifestFilters(); // properties and parameters derive from data editors - composition.WithCollectionBuilder() + composition.DataEditors() .Add(() => composition.TypeLoader.GetDataEditors()); composition.RegisterUnique(); composition.RegisterUnique(); + // Used to determine if a datatype/editor should be storing/tracking + // references to media item/s + composition.DataValueReferenceFactories(); + // register a server registrar, by default it's the db registrar composition.RegisterUnique(f => { @@ -101,13 +105,13 @@ namespace Umbraco.Core.Runtime factory.GetInstance(), true, new DatabaseServerMessengerOptions())); - composition.WithCollectionBuilder() + composition.CacheRefreshers() .Add(() => composition.TypeLoader.GetCacheRefreshers()); - composition.WithCollectionBuilder() + composition.PackageActions() .Add(() => composition.TypeLoader.GetPackageActions()); - composition.WithCollectionBuilder() + composition.PropertyValueConverters() .Append(composition.TypeLoader.GetTypes()); composition.RegisterUnique(); @@ -115,7 +119,7 @@ namespace Umbraco.Core.Runtime composition.RegisterUnique(factory => new DefaultShortStringHelper(new DefaultShortStringHelperConfig().WithDefault(factory.GetInstance()))); - composition.WithCollectionBuilder() + composition.UrlSegmentProviders() .Append(); composition.RegisterUnique(factory => new MigrationBuilder(factory)); diff --git a/src/Umbraco.Core/Runtime/CoreRuntime.cs b/src/Umbraco.Core/Runtime/CoreRuntime.cs index 5b069641c4..b852aff2ff 100644 --- a/src/Umbraco.Core/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Core/Runtime/CoreRuntime.cs @@ -27,6 +27,17 @@ namespace Umbraco.Core.Runtime private IFactory _factory; private RuntimeState _state; + [Obsolete("Use the ctor with all parameters instead")] + public CoreRuntime() + { + } + + public CoreRuntime(ILogger logger, IMainDom mainDom) + { + MainDom = mainDom; + Logger = logger; + } + /// /// Gets the logger. /// @@ -45,14 +56,22 @@ namespace Umbraco.Core.Runtime /// public IRuntimeState State => _state; + public IMainDom MainDom { get; private set; } + /// public virtual IFactory Boot(IRegister register) { // create and register the essential services // ie the bare minimum required to boot +#pragma warning disable CS0618 // Type or member is obsolete // loggers - var logger = Logger = GetLogger(); + // TODO: Removes this in netcore, this is purely just backwards compat ugliness + var logger = GetLogger(); + if (logger != Logger) + Logger = logger; +#pragma warning restore CS0618 // Type or member is obsolete + var profiler = Profiler = GetProfiler(); var profilingLogger = ProfilingLogger = new ProfilingLogger(logger, profiler); @@ -125,12 +144,16 @@ namespace Umbraco.Core.Runtime Level = RuntimeLevel.Boot }; - // main dom - var mainDom = new MainDom(Logger); + // TODO: remove this in netcore, this is purely backwards compat hacks with the empty ctor + if (MainDom == null) + { + MainDom = new MainDom(Logger, new MainDomSemaphoreLock(Logger)); + } + // create the composition composition = new Composition(register, typeLoader, ProfilingLogger, _state, configs); - composition.RegisterEssentials(Logger, Profiler, ProfilingLogger, mainDom, appCaches, databaseFactory, typeLoader, _state); + composition.RegisterEssentials(Logger, Profiler, ProfilingLogger, MainDom, appCaches, databaseFactory, typeLoader, _state); // run handlers RuntimeOptions.DoRuntimeEssentials(composition, appCaches, typeLoader, databaseFactory); @@ -140,15 +163,21 @@ namespace Umbraco.Core.Runtime Compose(composition); // acquire the main domain - if this fails then anything that should be registered with MainDom will not operate - AcquireMainDom(mainDom); + AcquireMainDom(MainDom); // determine our runtime level DetermineRuntimeLevel(databaseFactory, ProfilingLogger); // get composers, and compose var composerTypes = ResolveComposerTypes(typeLoader); - composition.WithCollectionBuilder(); - var composers = new Composers(composition, composerTypes, ProfilingLogger); + + IEnumerable enableDisableAttributes; + using (ProfilingLogger.DebugDuration("Scanning enable/disable composer attributes")) + { + enableDisableAttributes = typeLoader.GetAssemblyAttributes(typeof(EnableComposerAttribute), typeof(DisableComposerAttribute)); + } + + var composers = new Composers(composition, composerTypes, enableDisableAttributes, ProfilingLogger); composers.Compose(); // create the factory @@ -157,6 +186,8 @@ namespace Umbraco.Core.Runtime // create & initialize the components _components = _factory.GetInstance(); _components.Initialize(); + + } catch (Exception e) { @@ -218,13 +249,13 @@ namespace Umbraco.Core.Runtime IOHelper.SetRootDirectory(path); } - private bool AcquireMainDom(MainDom mainDom) + private bool AcquireMainDom(IMainDom mainDom) { using (var timer = ProfilingLogger.DebugDuration("Acquiring MainDom.", "Acquired.")) { try { - return mainDom.Acquire(); + return mainDom.IsMainDom; } catch { @@ -301,11 +332,9 @@ namespace Umbraco.Core.Runtime protected virtual IEnumerable GetComposerTypes(TypeLoader typeLoader) => typeLoader.GetTypes(); - /// - /// Gets a logger. - /// + [Obsolete("Don't use this method, the logger should be injected into the " + nameof(CoreRuntime))] protected virtual ILogger GetLogger() - => SerilogLogger.CreateWithDefaultConfiguration(); + => Logger ?? SerilogLogger.CreateWithDefaultConfiguration(); // TODO: Remove this in netcore, this purely just backwards compat ugliness /// /// Gets a profiler. diff --git a/src/Umbraco.Core/IMainDom.cs b/src/Umbraco.Core/Runtime/IMainDom.cs similarity index 88% rename from src/Umbraco.Core/IMainDom.cs rename to src/Umbraco.Core/Runtime/IMainDom.cs index 3a8cd13ff1..444fc1c7d0 100644 --- a/src/Umbraco.Core/IMainDom.cs +++ b/src/Umbraco.Core/Runtime/IMainDom.cs @@ -1,5 +1,6 @@ using System; +// TODO: Can't change namespace due to breaking changes, change in netcore namespace Umbraco.Core { /// @@ -14,6 +15,9 @@ namespace Umbraco.Core /// /// Gets a value indicating whether the current domain is the main domain. /// + /// + /// When the first call is made to this there will generally be some logic executed to acquire a distributed lock lease. + /// bool IsMainDom { get; } /// @@ -35,4 +39,4 @@ namespace Umbraco.Core /// is guaranteed to execute before the AppDomain releases the main domain status. bool Register(Action install, Action release, int weight = 100); } -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/Runtime/IMainDomLock.cs b/src/Umbraco.Core/Runtime/IMainDomLock.cs new file mode 100644 index 0000000000..6a62f48194 --- /dev/null +++ b/src/Umbraco.Core/Runtime/IMainDomLock.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; + +namespace Umbraco.Core.Runtime +{ + /// + /// An application-wide distributed lock + /// + /// + /// Disposing releases the lock + /// + public interface IMainDomLock : IDisposable + { + /// + /// Acquires an application-wide distributed lock + /// + /// + /// + /// An awaitable boolean value which will be false if the elapsed millsecondsTimeout value is exceeded + /// + Task AcquireLockAsync(int millisecondsTimeout); + + /// + /// Wait on a background thread to receive a signal from another AppDomain + /// + /// + Task ListenAsync(); + } +} diff --git a/src/Umbraco.Core/Runtime/MainDom.cs b/src/Umbraco.Core/Runtime/MainDom.cs new file mode 100644 index 0000000000..e6780ec876 --- /dev/null +++ b/src/Umbraco.Core/Runtime/MainDom.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Web.Hosting; +using Umbraco.Core; +using Umbraco.Core.Logging; +using Umbraco.Core.Persistence; + +namespace Umbraco.Core.Runtime +{ + + /// + /// Provides the full implementation of . + /// + /// + /// When an AppDomain starts, it tries to acquire the main domain status. + /// When an AppDomain stops (eg the application is restarting) it should release the main domain status. + /// + internal class MainDom : IMainDom, IRegisteredObject, IDisposable + { + #region Vars + + private readonly ILogger _logger; + private readonly IMainDomLock _mainDomLock; + + // our own lock for local consistency + private object _locko = new object(); + + private bool _isInitialized; + // indicates whether... + private bool _isMainDom; // we are the main domain + private volatile bool _signaled; // we have been signaled + + // actions to run before releasing the main domain + private readonly List> _callbacks = new List>(); + + private const int LockTimeoutMilliseconds = 40000; // 40 seconds + + #endregion + + #region Ctor + + // initializes a new instance of MainDom + public MainDom(ILogger logger, IMainDomLock systemLock) + { + HostingEnvironment.RegisterObject(this); + + _logger = logger; + _mainDomLock = systemLock; + } + + #endregion + + /// + /// Registers a resource that requires the current AppDomain to be the main domain to function. + /// + /// An action to execute before the AppDomain releases the main domain status. + /// An optional weight (lower goes first). + /// A value indicating whether it was possible to register. + public bool Register(Action release, int weight = 100) + => Register(null, release, weight); + + /// + /// Registers a resource that requires the current AppDomain to be the main domain to function. + /// + /// An action to execute when registering. + /// An action to execute before the AppDomain releases the main domain status. + /// An optional weight (lower goes first). + /// A value indicating whether it was possible to register. + /// If registering is successful, then the action + /// is guaranteed to execute before the AppDomain releases the main domain status. + public bool Register(Action install, Action release, int weight = 100) + { + lock (_locko) + { + if (_signaled) return false; + if (_isMainDom == false) + { + _logger.Warn("Register called when MainDom has not been acquired"); + return false; + } + + install?.Invoke(); + if (release != null) + _callbacks.Add(new KeyValuePair(weight, release)); + return true; + } + } + + // handles the signal requesting that the main domain is released + private void OnSignal(string source) + { + // once signaled, we stop waiting, but then there is the hosting environment + // so we have to make sure that we only enter that method once + + lock (_locko) + { + _logger.Debug("Signaled ({Signaled}) ({SignalSource})", _signaled ? "again" : "first", source); + if (_signaled) return; + if (_isMainDom == false) return; // probably not needed + _signaled = true; + + try + { + _logger.Info("Stopping ({SignalSource})", source); + foreach (var callback in _callbacks.OrderBy(x => x.Key).Select(x => x.Value)) + { + try + { + callback(); // no timeout on callbacks + } + catch (Exception e) + { + _logger.Error(e, "Error while running callback"); + continue; + } + } + + _logger.Debug("Stopped ({SignalSource})", source); + } + finally + { + // in any case... + _isMainDom = false; + _mainDomLock.Dispose(); + _logger.Info("Released ({SignalSource})", source); + } + + } + } + + // acquires the main domain + private bool Acquire() + { + // if signaled, too late to acquire, give up + // the handler is not installed so that would be the hosting environment + if (_signaled) + { + _logger.Info("Cannot acquire (signaled)."); + return false; + } + + _logger.Info("Acquiring."); + + // Get the lock + var acquired = _mainDomLock.AcquireLockAsync(LockTimeoutMilliseconds).GetAwaiter().GetResult(); + + if (!acquired) + { + _logger.Info("Cannot acquire (timeout)."); + + // In previous versions we'd let a TimeoutException be thrown + // and the appdomain would not start. We have the opportunity to allow it to + // start without having MainDom? This would mean that it couldn't write + // to nucache/examine and would only be ok if this was a super short lived appdomain. + // maybe safer to just keep throwing in this case. + + throw new TimeoutException("Cannot acquire MainDom"); + // return false; + } + + try + { + // Listen for the signal from another AppDomain coming online to release the lock + _mainDomLock.ListenAsync().ContinueWith(_ => OnSignal("signal")); + } + catch (OperationCanceledException ex) + { + // the waiting task could be canceled if this appdomain is naturally shutting down, we'll just swallow this exception + _logger.Warn(ex, ex.Message); + } + + _logger.Info("Acquired."); + return true; + } + + /// + /// Gets a value indicating whether the current domain is the main domain. + /// + /// + /// The lazy initializer call will only call the Acquire callback when it's not been initialized, else it will just return + /// the value from _isMainDom which means when we set _isMainDom to false again after being signaled, this will return false; + /// + public bool IsMainDom => LazyInitializer.EnsureInitialized(ref _isMainDom, ref _isInitialized, ref _locko, () => Acquire()); + + // IRegisteredObject + void IRegisteredObject.Stop(bool immediate) + { + OnSignal("environment"); // will run once + + // The web app is stopping, need to wind down + Dispose(true); + + HostingEnvironment.UnregisterObject(this); + } + + #region IDisposable Support + + // This code added to correctly implement the disposable pattern. + + private bool disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + _mainDomLock.Dispose(); + } + + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(true); + } + + #endregion + + public static string GetMainDomId() + { + // HostingEnvironment.ApplicationID is null in unit tests, making ReplaceNonAlphanumericChars fail + var appId = HostingEnvironment.ApplicationID?.ReplaceNonAlphanumericChars(string.Empty) ?? string.Empty; + + // combining with the physical path because if running on eg IIS Express, + // two sites could have the same appId even though they are different. + // + // now what could still collide is... two sites, running in two different processes + // and having the same appId, and running on the same app physical path + // + // we *cannot* use the process ID here because when an AppPool restarts it is + // a new process for the same application path + + var appPath = HostingEnvironment.ApplicationPhysicalPath?.ToLowerInvariant() ?? string.Empty; + var hash = (appId + ":::" + appPath).GenerateHash(); + + return hash; + } + } +} diff --git a/src/Umbraco.Core/Runtime/MainDomSemaphoreLock.cs b/src/Umbraco.Core/Runtime/MainDomSemaphoreLock.cs new file mode 100644 index 0000000000..2dcc37e25f --- /dev/null +++ b/src/Umbraco.Core/Runtime/MainDomSemaphoreLock.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Umbraco.Core.Logging; + +namespace Umbraco.Core.Runtime +{ + /// + /// Uses a system-wide Semaphore and EventWaitHandle to synchronize the current AppDomain + /// + internal class MainDomSemaphoreLock : IMainDomLock + { + private readonly SystemLock _systemLock; + + // event wait handle used to notify current main domain that it should + // release the lock because a new domain wants to be the main domain + private readonly EventWaitHandle _signal; + private readonly ILogger _logger; + private IDisposable _lockRelease; + + public MainDomSemaphoreLock(ILogger logger) + { + var lockName = "UMBRACO-" + MainDom.GetMainDomId() + "-MAINDOM-LCK"; + _systemLock = new SystemLock(lockName); + + var eventName = "UMBRACO-" + MainDom.GetMainDomId() + "-MAINDOM-EVT"; + _signal = new EventWaitHandle(false, EventResetMode.AutoReset, eventName); + _logger = logger; + } + + //WaitOneAsync (ext method) will wait for a signal without blocking the main thread, the waiting is done on a background thread + public Task ListenAsync() => _signal.WaitOneAsync(); + + public Task AcquireLockAsync(int millisecondsTimeout) + { + // signal other instances that we want the lock, then wait on the lock, + // which may timeout, and this is accepted - see comments below + + // signal, then wait for the lock, then make sure the event is + // reset (maybe there was noone listening..) + _signal.Set(); + + // if more than 1 instance reach that point, one will get the lock + // and the other one will timeout, which is accepted + + //This can throw a TimeoutException - in which case should this be in a try/finally to ensure the signal is always reset. + try + { + _lockRelease = _systemLock.Lock(millisecondsTimeout); + return Task.FromResult(true); + } + catch (TimeoutException ex) + { + _logger.Error(ex); + return Task.FromResult(false); + } + finally + { + // we need to reset the event, because otherwise we would end up + // signaling ourselves and committing suicide immediately. + // only 1 instance can reach that point, but other instances may + // have started and be trying to get the lock - they will timeout, + // which is accepted + + _signal.Reset(); + } + } + + #region IDisposable Support + private bool disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + _lockRelease?.Dispose(); + _signal.Close(); + _signal.Dispose(); + } + + disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + #endregion + } +} diff --git a/src/Umbraco.Core/Runtime/SqlMainDomLock.cs b/src/Umbraco.Core/Runtime/SqlMainDomLock.cs new file mode 100644 index 0000000000..4433a8e307 --- /dev/null +++ b/src/Umbraco.Core/Runtime/SqlMainDomLock.cs @@ -0,0 +1,413 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Umbraco.Core.Logging; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.Dtos; +using Umbraco.Core.Persistence.Mappers; +using Umbraco.Core.Persistence.SqlSyntax; + +namespace Umbraco.Core.Runtime +{ + internal class SqlMainDomLock : IMainDomLock + { + private string _lockId; + private const string MainDomKey = "Umbraco.Core.Runtime.SqlMainDom"; + private const string UpdatedSuffix = "_updated"; + private readonly ILogger _logger; + private IUmbracoDatabase _db; + private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private SqlServerSyntaxProvider _sqlServerSyntax = new SqlServerSyntaxProvider(); + private bool _mainDomChanging = false; + private readonly UmbracoDatabaseFactory _dbFactory; + private bool _hasError; + private object _locker = new object(); + + public SqlMainDomLock(ILogger logger) + { + // unique id for our appdomain, this is more unique than the appdomain id which is just an INT counter to its safer + _lockId = Guid.NewGuid().ToString(); + _logger = logger; + _dbFactory = new UmbracoDatabaseFactory( + Constants.System.UmbracoConnectionName, + _logger, + new Lazy(() => new Persistence.Mappers.MapperCollection(Enumerable.Empty()))); + } + + public async Task AcquireLockAsync(int millisecondsTimeout) + { + if (!(_dbFactory.SqlContext.SqlSyntax is SqlServerSyntaxProvider sqlServerSyntaxProvider)) + throw new NotSupportedException("SqlMainDomLock is only supported for Sql Server"); + + _sqlServerSyntax = sqlServerSyntaxProvider; + + _logger.Debug("Acquiring lock..."); + + var db = GetDatabase(); + + var tempId = Guid.NewGuid().ToString(); + + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + try + { + // wait to get a write lock + _sqlServerSyntax.WriteLock(db, TimeSpan.FromMilliseconds(millisecondsTimeout), Constants.Locks.MainDom); + } + catch (Exception ex) + { + if (IsLockTimeoutException(ex)) + { + _logger.Error(ex, "Sql timeout occurred, could not acquire MainDom."); + _hasError = true; + return false; + } + + // unexpected (will be caught below) + throw; + } + + var result = InsertLockRecord(tempId); //we change the row to a random Id to signal other MainDom to shutdown + if (result == RecordPersistenceType.Insert) + { + // if we've inserted, then there was no MainDom so we can instantly acquire + + // TODO: see the other TODO, could we just delete the row and that would indicate that we + // are MainDom? then we don't leave any orphan rows behind. + + InsertLockRecord(_lockId); // so update with our appdomain id + _logger.Debug("Acquired with ID {LockId}", _lockId); + return true; + } + + // if we've updated, this means there is an active MainDom, now we need to wait to + // for the current MainDom to shutdown which also requires releasing our write lock + } + catch (Exception ex) + { + ResetDatabase(); + // unexpected + _logger.Error(ex, "Unexpected error, cannot acquire MainDom"); + _hasError = true; + return false; + } + finally + { + db?.CompleteTransaction(); + } + + return await WaitForExistingAsync(tempId, millisecondsTimeout); + } + + public Task ListenAsync() + { + if (_hasError) + { + _logger.Warn("Could not acquire MainDom, listening is canceled."); + return Task.CompletedTask; + } + + // Create a long running task (dedicated thread) + // to poll to check if we are still the MainDom registered in the DB + return Task.Factory.StartNew(ListeningLoop, _cancellationTokenSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + } + + private void ListeningLoop() + { + while (true) + { + // poll every 1 second + Thread.Sleep(1000); + + lock (_locker) + { + // If cancellation has been requested we will just exit. Depending on timing of the shutdown, + // we will have already flagged _mainDomChanging = true, or we're shutting down faster than + // the other MainDom is taking to startup. In this case the db row will just be deleted and the + // new MainDom will just take over. + if (_cancellationTokenSource.IsCancellationRequested) + return; + + var db = GetDatabase(); + + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + // get a read lock + _sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom); + + // TODO: We could in theory just check if the main dom row doesn't exist, that could indicate that + // we are still the maindom. An empty value might be better because then we won't have any orphan rows + // if the app is terminated. Could that work? + + if (!IsMainDomValue(_lockId)) + { + // we are no longer main dom, another one has come online, exit + _mainDomChanging = true; + _logger.Debug("Detected new booting application, releasing MainDom lock."); + return; + } + } + catch (Exception ex) + { + ResetDatabase(); + // unexpected + _logger.Error(ex, "Unexpected error, listening is canceled."); + _hasError = true; + return; + } + finally + { + db?.CompleteTransaction(); + } + } + + } + } + + private void ResetDatabase() + { + if (_db.InTransaction) + _db.AbortTransaction(); + _db.Dispose(); + _db = null; + } + + private IUmbracoDatabase GetDatabase() + { + if (_db != null) + return _db; + + _db = _dbFactory.CreateDatabase(); + return _db; + } + + /// + /// Wait for any existing MainDom to release so we can continue booting + /// + /// + /// + /// + private Task WaitForExistingAsync(string tempId, int millisecondsTimeout) + { + var updatedTempId = tempId + UpdatedSuffix; + + return Task.Run(() => + { + var db = GetDatabase(); + var watch = new Stopwatch(); + watch.Start(); + while(true) + { + // poll very often, we need to take over as fast as we can + Thread.Sleep(100); + + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + // get a read lock + _sqlServerSyntax.ReadLock(db, Constants.Locks.MainDom); + + // the row + var mainDomRows = db.Fetch("SELECT * FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey }); + + if (mainDomRows.Count == 0 || mainDomRows[0].Value == updatedTempId) + { + // the other main dom has updated our record + // Or the other maindom shutdown super fast and just deleted the record + // which indicates that we + // can acquire it and it has shutdown. + + _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + + // so now we update the row with our appdomain id + InsertLockRecord(_lockId); + _logger.Debug("Acquired with ID {LockId}", _lockId); + return true; + } + else if (mainDomRows.Count == 1 && !mainDomRows[0].Value.StartsWith(tempId)) + { + // in this case, the prefixed ID is different which means + // another new AppDomain has come online and is wanting to take over. In that case, we will not + // acquire. + + _logger.Debug("Cannot acquire, another booting application detected."); + + return false; + } + } + catch (Exception ex) + { + ResetDatabase(); + + if (IsLockTimeoutException(ex)) + { + _logger.Error(ex, "Sql timeout occurred, waiting for existing MainDom is canceled."); + _hasError = true; + return false; + } + // unexpected + _logger.Error(ex, "Unexpected error, waiting for existing MainDom is canceled."); + _hasError = true; + return false; + } + finally + { + db?.CompleteTransaction(); + } + + if (watch.ElapsedMilliseconds >= millisecondsTimeout) + { + // if the timeout has elapsed, it either means that the other main dom is taking too long to shutdown, + // or it could mean that the previous appdomain was terminated and didn't clear out the main dom SQL row + // and it's just been left as an orphan row. + // There's really know way of knowing unless we are constantly updating the row for the current maindom + // which isn't ideal. + // So... we're going to 'just' take over, if the writelock works then we'll assume we're ok + + _logger.Debug("Timeout elapsed, assuming orphan row, acquiring MainDom."); + + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + + // so now we update the row with our appdomain id + InsertLockRecord(_lockId); + _logger.Debug("Acquired with ID {LockId}", _lockId); + return true; + } + catch (Exception ex) + { + ResetDatabase(); + + if (IsLockTimeoutException(ex)) + { + // something is wrong, we cannot acquire, not much we can do + _logger.Error(ex, "Sql timeout occurred, could not forcibly acquire MainDom."); + _hasError = true; + return false; + } + _logger.Error(ex, "Unexpected error, could not forcibly acquire MainDom."); + _hasError = true; + return false; + } + finally + { + db?.CompleteTransaction(); + } + } + } + }, _cancellationTokenSource.Token); + } + + /// + /// Inserts or updates the key/value row + /// + private RecordPersistenceType InsertLockRecord(string id) + { + var db = GetDatabase(); + return db.InsertOrUpdate(new KeyValueDto + { + Key = MainDomKey, + Value = id, + Updated = DateTime.Now + }); + } + + /// + /// Checks if the DB row value is equals the value + /// + /// + private bool IsMainDomValue(string val) + { + var db = GetDatabase(); + return db.ExecuteScalar("SELECT COUNT(*) FROM umbracoKeyValue WHERE [key] = @key AND [value] = @val", + new { key = MainDomKey, val = val }) == 1; + } + + /// + /// Checks if the exception is an SQL timeout + /// + /// + /// + private bool IsLockTimeoutException(Exception exception) => exception is SqlException sqlException && sqlException.Number == 1222; + + #region IDisposable Support + private bool _disposedValue = false; // To detect redundant calls + + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + lock (_locker) + { + // immediately cancel all sub-tasks, we don't want them to keep querying + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + + var db = GetDatabase(); + try + { + db.BeginTransaction(IsolationLevel.ReadCommitted); + + // get a write lock + _sqlServerSyntax.WriteLock(db, Constants.Locks.MainDom); + + // When we are disposed, it means we have released the MainDom lock + // and called all MainDom release callbacks, in this case + // if another maindom is actually coming online we need + // to signal to the MainDom coming online that we have shutdown. + // To do that, we update the existing main dom DB record with a suffixed "_updated" string. + // Otherwise, if we are just shutting down, we want to just delete the row. + if (_mainDomChanging) + { + _logger.Debug("Releasing MainDom, updating row, new application is booting."); + db.Execute($"UPDATE umbracoKeyValue SET [value] = [value] + '{UpdatedSuffix}' WHERE [key] = @key", new { key = MainDomKey }); + } + else + { + _logger.Debug("Releasing MainDom, deleting row, application is shutting down."); + db.Execute("DELETE FROM umbracoKeyValue WHERE [key] = @key", new { key = MainDomKey }); + } + } + catch (Exception ex) + { + ResetDatabase(); + _logger.Error(ex, "Unexpected error during dipsose."); + _hasError = true; + } + finally + { + db?.CompleteTransaction(); + ResetDatabase(); + } + } + } + + _disposedValue = true; + } + } + + // This code added to correctly implement the disposable pattern. + public void Dispose() + { + // Do not change this code. Put cleanup code in Dispose(bool disposing) above. + Dispose(true); + } + #endregion + + } +} diff --git a/src/Umbraco.Core/RuntimeState.cs b/src/Umbraco.Core/RuntimeState.cs index 5d34fe70a1..74ec70828b 100644 --- a/src/Umbraco.Core/RuntimeState.cs +++ b/src/Umbraco.Core/RuntimeState.cs @@ -1,8 +1,8 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Web; using Semver; +using Umbraco.Core.Collections; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Exceptions; @@ -22,7 +22,7 @@ namespace Umbraco.Core private readonly ILogger _logger; private readonly IUmbracoSettingsSection _settings; private readonly IGlobalSettings _globalSettings; - private readonly HashSet _applicationUrls = new HashSet(); + private readonly ConcurrentHashSet _applicationUrls = new ConcurrentHashSet(); private readonly Lazy _mainDom; private readonly Lazy _serverRegistrar; diff --git a/src/Umbraco.Core/Scoping/IScopeProvider.cs b/src/Umbraco.Core/Scoping/IScopeProvider.cs index 6c9eb63ba0..96bb939f8e 100644 --- a/src/Umbraco.Core/Scoping/IScopeProvider.cs +++ b/src/Umbraco.Core/Scoping/IScopeProvider.cs @@ -13,7 +13,7 @@ namespace Umbraco.Core.Scoping /// Provides scopes. /// public interface IScopeProvider - { + { /// /// Creates an ambient scope. /// diff --git a/src/Umbraco.Core/Scoping/Scope.cs b/src/Umbraco.Core/Scoping/Scope.cs index e9dd04c5fa..84273e23da 100644 --- a/src/Umbraco.Core/Scoping/Scope.cs +++ b/src/Umbraco.Core/Scoping/Scope.cs @@ -33,8 +33,6 @@ namespace Umbraco.Core.Scoping private ICompletable _fscope; private IEventDispatcher _eventDispatcher; - private const IsolationLevel DefaultIsolationLevel = IsolationLevel.RepeatableRead; - // initializes a new scope private Scope(ScopeProvider scopeProvider, ILogger logger, FileSystems fileSystems, Scope parent, ScopeContext scopeContext, bool detachable, @@ -205,7 +203,7 @@ namespace Umbraco.Core.Scoping { if (_isolationLevel != IsolationLevel.Unspecified) return _isolationLevel; if (ParentScope != null) return ParentScope.IsolationLevel; - return DefaultIsolationLevel; + return Database.SqlContext.SqlSyntax.DefaultIsolationLevel; } } @@ -488,37 +486,9 @@ namespace Umbraco.Core.Scoping ?? (_logUncompletedScopes = Current.Configs.CoreDebug().LogUncompletedScopes)).Value; /// - public void ReadLock(params int[] lockIds) - { - // soon as we get Database, a transaction is started - - if (Database.Transaction.IsolationLevel < IsolationLevel.RepeatableRead) - throw new InvalidOperationException("A transaction with minimum RepeatableRead isolation level is required."); - - // *not* using a unique 'WHERE IN' query here because the *order* of lockIds is important to avoid deadlocks - foreach (var lockId in lockIds) - { - var i = Database.ExecuteScalar("SELECT value FROM umbracoLock WHERE id=@id", new { id = lockId }); - if (i == null) // ensure we are actually locking! - throw new Exception($"LockObject with id={lockId} does not exist."); - } - } + public void ReadLock(params int[] lockIds) => Database.SqlContext.SqlSyntax.ReadLock(Database, lockIds); /// - public void WriteLock(params int[] lockIds) - { - // soon as we get Database, a transaction is started - - if (Database.Transaction.IsolationLevel < IsolationLevel.RepeatableRead) - throw new InvalidOperationException("A transaction with minimum RepeatableRead isolation level is required."); - - // *not* using a unique 'WHERE IN' query here because the *order* of lockIds is important to avoid deadlocks - foreach (var lockId in lockIds) - { - var i = Database.Execute("UPDATE umbracoLock SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id", new { id = lockId }); - if (i == 0) // ensure we are actually locking! - throw new Exception($"LockObject with id={lockId} does not exist."); - } - } + public void WriteLock(params int[] lockIds) => Database.SqlContext.SqlSyntax.WriteLock(Database, lockIds); } } diff --git a/src/Umbraco.Core/Security/BackOfficeUserStore.cs b/src/Umbraco.Core/Security/BackOfficeUserStore.cs index 085a7b7a5b..dc271452e1 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserStore.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using System.Web.Security; using Microsoft.AspNet.Identity; using Umbraco.Core.Configuration; -using Umbraco.Core.Exceptions; using Umbraco.Core.Mapping; using Umbraco.Core.Models; using Umbraco.Core.Models.Identity; @@ -217,7 +216,8 @@ namespace Umbraco.Core.Security { ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - if (string.IsNullOrEmpty(passwordHash)) throw new ArgumentNullOrEmptyException(nameof(passwordHash)); + if (passwordHash == null) throw new ArgumentNullException(nameof(passwordHash)); + if (string.IsNullOrEmpty(passwordHash)) throw new ArgumentException("Value can't be empty.", nameof(passwordHash)); user.PasswordHash = passwordHash; @@ -329,7 +329,7 @@ namespace Umbraco.Core.Security { ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - if (login == null) throw new ArgumentNullException("login"); + if (login == null) throw new ArgumentNullException(nameof(login)); var logins = user.Logins; var instance = new IdentityUserLogin(login.LoginProvider, login.ProviderKey, user.Id); @@ -348,7 +348,7 @@ namespace Umbraco.Core.Security { ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - if (login == null) throw new ArgumentNullException("login"); + if (login == null) throw new ArgumentNullException(nameof(login)); var provider = login.LoginProvider; var key = login.ProviderKey; @@ -379,7 +379,7 @@ namespace Umbraco.Core.Security public Task FindAsync(UserLoginInfo login) { ThrowIfDisposed(); - if (login == null) throw new ArgumentNullException("login"); + if (login == null) throw new ArgumentNullException(nameof(login)); //get all logins associated with the login id var result = _externalLoginService.Find(login).ToArray(); @@ -413,7 +413,8 @@ namespace Umbraco.Core.Security { ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("Value cannot be null or whitespace.", "roleName"); + if (roleName == null) throw new ArgumentNullException(nameof(roleName)); + if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(roleName)); var userRole = user.Roles.SingleOrDefault(r => r.RoleId == roleName); @@ -434,7 +435,8 @@ namespace Umbraco.Core.Security { ThrowIfDisposed(); if (user == null) throw new ArgumentNullException(nameof(user)); - if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("Value cannot be null or whitespace.", "roleName"); + if (roleName == null) throw new ArgumentNullException(nameof(roleName)); + if (string.IsNullOrWhiteSpace(roleName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(roleName)); var userRole = user.Roles.SingleOrDefault(r => r.RoleId == roleName); diff --git a/src/Umbraco.Core/Serialization/JsonNetSerializer.cs b/src/Umbraco.Core/Serialization/JsonNetSerializer.cs index 1b5a9c6caf..46f23ca35e 100644 --- a/src/Umbraco.Core/Serialization/JsonNetSerializer.cs +++ b/src/Umbraco.Core/Serialization/JsonNetSerializer.cs @@ -60,7 +60,7 @@ namespace Umbraco.Core.Serialization /// public IStreamedResult ToStream(object input) { - string s = JsonConvert.SerializeObject(input, Formatting.Indented, _settings); + string s = JsonConvert.SerializeObject(input, Formatting.None, _settings); byte[] bytes = Encoding.UTF8.GetBytes(s); MemoryStream ms = new MemoryStream(bytes); diff --git a/src/Umbraco.Core/Services/ContentTypeServiceExtensions.cs b/src/Umbraco.Core/Services/ContentTypeServiceExtensions.cs index f643039dca..37f1e5127f 100644 --- a/src/Umbraco.Core/Services/ContentTypeServiceExtensions.cs +++ b/src/Umbraco.Core/Services/ContentTypeServiceExtensions.cs @@ -7,6 +7,21 @@ namespace Umbraco.Core.Services { public static class ContentTypeServiceExtensions { + /// + /// Gets all of the element types (e.g. content types that have been marked as an element type). + /// + /// The content type service. + /// Returns all the element types. + public static IEnumerable GetAllElementTypes(this IContentTypeService contentTypeService) + { + if (contentTypeService == null) + { + return Enumerable.Empty(); + } + + return contentTypeService.GetAll().Where(x => x.IsElement); + } + /// /// Returns the available composite content types for a given content type /// @@ -22,12 +37,14 @@ namespace Umbraco.Core.Services /// This is required because in the case of creating/modifying a content type because new property types being added to it are not yet persisted so cannot /// be looked up via the db, they need to be passed in. /// + /// Wether the composite content types should be applicable for an element type /// internal static ContentTypeAvailableCompositionsResults GetAvailableCompositeContentTypes(this IContentTypeService ctService, IContentTypeComposition source, IContentTypeComposition[] allContentTypes, string[] filterContentTypes = null, - string[] filterPropertyTypes = null) + string[] filterPropertyTypes = null, + bool isElement = false) { filterContentTypes = filterContentTypes == null ? Array.Empty() @@ -46,7 +63,7 @@ namespace Umbraco.Core.Services .Select(c => c.Alias) .Union(filterPropertyTypes) .ToArray(); - + var sourceId = source?.Id ?? 0; // find out if any content type uses this content type @@ -64,8 +81,9 @@ namespace Umbraco.Core.Services x => x.Id)); // usable types are those that are top-level + // do not allow element types to be composed by non-element types as this will break the model generation in ModelsBuilder var usableContentTypes = allContentTypes - .Where(x => x.ContentTypeComposition.Any() == false).ToArray(); + .Where(x => x.ContentTypeComposition.Any() == false && (isElement == false || x.IsElement)).ToArray(); foreach (var x in usableContentTypes) list.Add(x); diff --git a/src/Umbraco.Core/Services/IContentTypeServiceBase.cs b/src/Umbraco.Core/Services/IContentTypeServiceBase.cs index b86494adb5..6ed3c85e91 100644 --- a/src/Umbraco.Core/Services/IContentTypeServiceBase.cs +++ b/src/Umbraco.Core/Services/IContentTypeServiceBase.cs @@ -25,7 +25,7 @@ namespace Umbraco.Core.Services /// /// Gets a content type. /// - TItem Get(int id); + new TItem Get(int id); /// /// Gets a content type. @@ -39,7 +39,13 @@ namespace Umbraco.Core.Services int Count(); + /// + /// Returns true or false depending on whether content nodes have been created based on the provided content type id. + /// + bool HasContentNodes(int id); + IEnumerable GetAll(params int[] ids); + IEnumerable GetAll(IEnumerable ids); IEnumerable GetDescendants(int id, bool andSelf); // parent-child axis IEnumerable GetComposedOf(int id); // composition axis diff --git a/src/Umbraco.Core/Services/IDataTypeService.cs b/src/Umbraco.Core/Services/IDataTypeService.cs index bb56e110cd..3ebfa95bfb 100644 --- a/src/Umbraco.Core/Services/IDataTypeService.cs +++ b/src/Umbraco.Core/Services/IDataTypeService.cs @@ -10,6 +10,13 @@ namespace Umbraco.Core.Services /// public interface IDataTypeService : IService { + /// + /// Returns a dictionary of content type s and the property type aliases that use a + /// + /// + /// + IReadOnlyDictionary> GetReferences(int id); + Attempt> CreateContainer(int parentId, string name, int userId = Constants.Security.SuperUserId); Attempt SaveContainer(EntityContainer container, int userId = Constants.Security.SuperUserId); EntityContainer GetContainer(int containerId); diff --git a/src/Umbraco.Core/Services/IRelationService.cs b/src/Umbraco.Core/Services/IRelationService.cs index ef22632d6e..bf8bcd5b2a 100644 --- a/src/Umbraco.Core/Services/IRelationService.cs +++ b/src/Umbraco.Core/Services/IRelationService.cs @@ -8,136 +8,162 @@ namespace Umbraco.Core.Services public interface IRelationService : IService { /// - /// Gets a by its Id + /// Gets a by its Id /// - /// Id of the - /// A object + /// Id of the + /// A object IRelation GetById(int id); /// - /// Gets a by its Id + /// Gets a by its Id /// - /// Id of the - /// A object + /// Id of the + /// A object IRelationType GetRelationTypeById(int id); /// - /// Gets a by its Id + /// Gets a by its Id /// - /// Id of the - /// A object + /// Id of the + /// A object IRelationType GetRelationTypeById(Guid id); /// - /// Gets a by its Alias + /// Gets a by its Alias /// - /// Alias of the - /// A object + /// Alias of the + /// A object IRelationType GetRelationTypeByAlias(string alias); /// - /// Gets all objects + /// Gets all objects /// /// Optional array of integer ids to return relations for - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetAllRelations(params int[] ids); /// - /// Gets all objects by their + /// Gets all objects by their /// - /// to retrieve Relations for - /// An enumerable list of objects - IEnumerable GetAllRelationsByRelationType(RelationType relationType); + /// to retrieve Relations for + /// An enumerable list of objects + IEnumerable GetAllRelationsByRelationType(IRelationType relationType); /// - /// Gets all objects by their 's Id + /// Gets all objects by their 's Id /// - /// Id of the to retrieve Relations for - /// An enumerable list of objects + /// Id of the to retrieve Relations for + /// An enumerable list of objects IEnumerable GetAllRelationsByRelationType(int relationTypeId); /// - /// Gets all objects + /// Gets all objects /// /// Optional array of integer ids to return relationtypes for - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetAllRelationTypes(params int[] ids); /// - /// Gets a list of objects by their parent Id + /// Gets a list of objects by their parent Id /// /// Id of the parent to retrieve relations for - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetByParentId(int id); /// - /// Gets a list of objects by their parent entity + /// Gets a list of objects by their parent Id + /// + /// Id of the parent to retrieve relations for + /// Alias of the type of relation to retrieve + /// An enumerable list of objects + IEnumerable GetByParentId(int id, string relationTypeAlias); + + /// + /// Gets a list of objects by their parent entity /// /// Parent Entity to retrieve relations for - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetByParent(IUmbracoEntity parent); /// - /// Gets a list of objects by their parent entity + /// Gets a list of objects by their parent entity /// /// Parent Entity to retrieve relations for /// Alias of the type of relation to retrieve - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetByParent(IUmbracoEntity parent, string relationTypeAlias); /// - /// Gets a list of objects by their child Id + /// Gets a list of objects by their child Id /// /// Id of the child to retrieve relations for - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetByChildId(int id); /// - /// Gets a list of objects by their child Entity + /// Gets a list of objects by their child Id + /// + /// Id of the child to retrieve relations for + /// Alias of the type of relation to retrieve + /// An enumerable list of objects + IEnumerable GetByChildId(int id, string relationTypeAlias); + + /// + /// Gets a list of objects by their child Entity /// /// Child Entity to retrieve relations for - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetByChild(IUmbracoEntity child); /// - /// Gets a list of objects by their child Entity + /// Gets a list of objects by their child Entity /// /// Child Entity to retrieve relations for /// Alias of the type of relation to retrieve - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetByChild(IUmbracoEntity child, string relationTypeAlias); /// - /// Gets a list of objects by their child or parent Id. + /// Gets a list of objects by their child or parent Id. /// Using this method will get you all relations regards of it being a child or parent relation. /// /// Id of the child or parent to retrieve relations for - /// An enumerable list of objects + /// An enumerable list of objects IEnumerable GetByParentOrChildId(int id); IEnumerable GetByParentOrChildId(int id, string relationTypeAlias); /// - /// Gets a list of objects by the Name of the + /// Gets a list of objects by the Name of the /// - /// Name of the to retrieve Relations for - /// An enumerable list of objects + /// Name of the to retrieve Relations for + /// An enumerable list of objects IEnumerable GetByRelationTypeName(string relationTypeName); /// - /// Gets a list of objects by the Alias of the + /// Gets a list of objects by the Alias of the /// - /// Alias of the to retrieve Relations for - /// An enumerable list of objects + /// Alias of the to retrieve Relations for + /// An enumerable list of objects IEnumerable GetByRelationTypeAlias(string relationTypeAlias); /// - /// Gets a list of objects by the Id of the + /// Gets a list of objects by the Id of the /// - /// Id of the to retrieve Relations for - /// An enumerable list of objects + /// Id of the to retrieve Relations for + /// An enumerable list of objects IEnumerable GetByRelationTypeId(int relationTypeId); + /// + /// Gets a paged result of + /// + /// + /// + /// + /// + /// + IEnumerable GetPagedByRelationTypeId(int relationTypeId, long pageIndex, int pageSize, out long totalRecords, Ordering ordering = null); + /// /// Gets the Child object from a Relation as an /// @@ -173,6 +199,26 @@ namespace Umbraco.Core.Services /// An enumerable list of IEnumerable GetParentEntitiesFromRelations(IEnumerable relations); + /// + /// Returns paged parent entities for a related child id + /// + /// + /// + /// + /// + /// + IEnumerable GetPagedParentEntitiesByChildId(int id, long pageIndex, int pageSize, out long totalChildren, params UmbracoObjectTypes[] entityTypes); + + /// + /// Returns paged child entities for a related parent id + /// + /// + /// + /// + /// + /// + IEnumerable GetPagedChildEntitiesByParentId(int id, long pageIndex, int pageSize, out long totalChildren, params UmbracoObjectTypes[] entityTypes); + /// /// Gets the Parent and Child objects from a list of Relations as a list of objects. /// @@ -186,7 +232,7 @@ namespace Umbraco.Core.Services /// Id of the parent /// Id of the child /// The type of relation to create - /// The created + /// The created IRelation Relate(int parentId, int childId, IRelationType relationType); /// @@ -195,7 +241,7 @@ namespace Umbraco.Core.Services /// Parent entity /// Child entity /// The type of relation to create - /// The created + /// The created IRelation Relate(IUmbracoEntity parent, IUmbracoEntity child, IRelationType relationType); /// @@ -204,7 +250,7 @@ namespace Umbraco.Core.Services /// Id of the parent /// Id of the child /// Alias of the type of relation to create - /// The created + /// The created IRelation Relate(int parentId, int childId, string relationTypeAlias); /// @@ -213,14 +259,14 @@ namespace Umbraco.Core.Services /// Parent entity /// Child entity /// Alias of the type of relation to create - /// The created + /// The created IRelation Relate(IUmbracoEntity parent, IUmbracoEntity child, string relationTypeAlias); /// - /// Checks whether any relations exists for the passed in . + /// Checks whether any relations exists for the passed in . /// - /// to check for relations - /// Returns True if any relations exists for the given , otherwise False + /// to check for relations + /// Returns True if any relations exists for the given , otherwise False bool HasRelations(IRelationType relationType); /// @@ -265,33 +311,35 @@ namespace Umbraco.Core.Services bool AreRelated(int parentId, int childId, string relationTypeAlias); /// - /// Saves a + /// Saves a /// /// Relation to save void Save(IRelation relation); + void Save(IEnumerable relations); + /// - /// Saves a + /// Saves a /// /// RelationType to Save void Save(IRelationType relationType); /// - /// Deletes a + /// Deletes a /// /// Relation to Delete void Delete(IRelation relation); /// - /// Deletes a + /// Deletes a /// /// RelationType to Delete void Delete(IRelationType relationType); /// - /// Deletes all objects based on the passed in + /// Deletes all objects based on the passed in /// - /// to Delete Relations for + /// to Delete Relations for void DeleteRelationsOfType(IRelationType relationType); } } diff --git a/src/Umbraco.Core/Services/Implement/ContentService.cs b/src/Umbraco.Core/Services/Implement/ContentService.cs index e49dcf4a12..1558b0170b 100644 --- a/src/Umbraco.Core/Services/Implement/ContentService.cs +++ b/src/Umbraco.Core/Services/Implement/ContentService.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.Globalization; using System.Linq; -using System.Text.RegularExpressions; using Umbraco.Core.Events; using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; @@ -12,7 +11,6 @@ using Umbraco.Core.Models.Membership; using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.Repositories; -using Umbraco.Core.Persistence.Repositories.Implement; using Umbraco.Core.Scoping; using Umbraco.Core.Services.Changes; @@ -757,11 +755,11 @@ namespace Umbraco.Core.Services.Implement { var publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - throw new InvalidOperationException("Cannot save (un)publishing content, use the dedicated SavePublished method."); + throw new InvalidOperationException($"Cannot save (un)publishing content with name: {content.Name} - and state: {content.PublishedState}, use the dedicated SavePublished method."); if (content.Name != null && content.Name.Length > 255) { - throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + throw new InvalidOperationException($"Content with the name {content.Name} cannot be more than 255 characters in length."); } var evtMsgs = EventMessagesFactory.Get(); @@ -886,6 +884,8 @@ namespace Umbraco.Core.Services.Implement { scope.WriteLock(Constants.Locks.ContentTree); + var allLangs = _languageRepository.GetMany().ToList(); + var saveEventArgs = new ContentSavingEventArgs(content, evtMsgs); if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, nameof(Saving))) return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); @@ -894,13 +894,13 @@ namespace Umbraco.Core.Services.Implement // if culture is '*', then publish them all (including variants) //this will create the correct culture impact even if culture is * or null - var impact = CultureImpact.Create(culture, _languageRepository.IsDefault(culture), content); + var impact = CultureImpact.Create(culture, IsDefaultCulture(allLangs, culture), content); // publish the culture(s) // we don't care about the response here, this response will be rechecked below but we need to set the culture info values now. content.PublishCulture(impact); - var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, userId, raiseEvents); + var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, allLangs, userId, raiseEvents); scope.Complete(); return result; } @@ -921,6 +921,8 @@ namespace Umbraco.Core.Services.Implement { scope.WriteLock(Constants.Locks.ContentTree); + var allLangs = _languageRepository.GetMany().ToList(); + var evtMsgs = EventMessagesFactory.Get(); var saveEventArgs = new ContentSavingEventArgs(content, evtMsgs); if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, nameof(Saving))) @@ -928,25 +930,23 @@ namespace Umbraco.Core.Services.Implement var varies = content.ContentType.VariesByCulture(); - if (cultures.Length == 0) + if (cultures.Length == 0 && !varies) { //no cultures specified and doesn't vary, so publish it, else nothing to publish - return !varies - ? SaveAndPublish(content, userId: userId, raiseEvents: raiseEvents) - : new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); + return SaveAndPublish(content, userId: userId, raiseEvents: raiseEvents); } if (cultures.Any(x => x == null || x == "*")) throw new InvalidOperationException("Only valid cultures are allowed to be used in this method, wildcards or nulls are not allowed"); - var impacts = cultures.Select(x => CultureImpact.Explicit(x, _languageRepository.IsDefault(x))); + var impacts = cultures.Select(x => CultureImpact.Explicit(x, IsDefaultCulture(allLangs, x))); // publish the culture(s) // we don't care about the response here, this response will be rechecked below but we need to set the culture info values now. foreach (var impact in impacts) content.PublishCulture(impact); - var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, userId, raiseEvents); + var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, allLangs, userId, raiseEvents); scope.Complete(); return result; } @@ -986,6 +986,8 @@ namespace Umbraco.Core.Services.Implement { scope.WriteLock(Constants.Locks.ContentTree); + var allLangs = _languageRepository.GetMany().ToList(); + var saveEventArgs = new ContentSavingEventArgs(content, evtMsgs); if (scope.Events.DispatchCancelable(Saving, this, saveEventArgs, nameof(Saving))) return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); @@ -993,26 +995,39 @@ namespace Umbraco.Core.Services.Implement // all cultures = unpublish whole if (culture == "*" || (!content.ContentType.VariesByCulture() && culture == null)) { + // It's important to understand that when the document varies by culture but the "*" is used, + // we are just unpublishing the whole document but leaving all of the culture's as-is. This is expected + // because we don't want to actually unpublish every culture and then the document, we just want everything + // to be non-routable so that when it's re-published all variants were as they were. + content.PublishedState = PublishedState.Unpublishing; + var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, allLangs, userId); + scope.Complete(); + return result; } else { - // If the culture we want to unpublish was already unpublished, nothing to do. - // To check for that we need to lookup the persisted content item - var persisted = content.HasIdentity ? GetById(content.Id) : null; + // Unpublish the culture, this will change the document state to Publishing! ... which is expected because this will + // essentially be re-publishing the document with the requested culture removed. + // The call to CommitDocumentChangesInternal will perform all the checks like if this is a mandatory culture or the last culture being unpublished + // and will then unpublish the document accordingly. + // If the result of this is false it means there was no culture to unpublish (i.e. it was already unpublished or it did not exist) + var removed = content.UnpublishCulture(culture); - if (persisted != null && !persisted.IsCulturePublished(culture)) + //save and publish any changes + var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, allLangs, userId); + + scope.Complete(); + + // In one case the result will be PublishStatusType.FailedPublishNothingToPublish which means that no cultures + // were specified to be published which will be the case when removed is false. In that case + // we want to swap the result type to PublishResultType.SuccessUnpublishAlready (that was the expectation before). + if (result.Result == PublishResultType.FailedPublishNothingToPublish && !removed) return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); - // unpublish the culture - content.UnpublishCulture(culture); + return result; } - - var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, userId); - scope.Complete(); - return result; } - } /// @@ -1047,15 +1062,35 @@ namespace Umbraco.Core.Services.Implement if (raiseEvents && scope.Events.DispatchCancelable(Saving, this, saveEventArgs, nameof(Saving))) return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); - var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, userId, raiseEvents); + var allLangs = _languageRepository.GetMany().ToList(); + + var result = CommitDocumentChangesInternal(scope, content, saveEventArgs, allLangs, userId, raiseEvents); scope.Complete(); return result; } } + /// + /// Handles a lot of business logic cases for how the document should be persisted + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// Business logic cases such: as unpublishing a mandatory culture, or unpublishing the last culture, checking for pending scheduled publishing, etc... is dealt with in this method. + /// There is quite a lot of cases to take into account along with logic that needs to deal with scheduled saving/publishing, branch saving/publishing, etc... + /// + /// private PublishResult CommitDocumentChangesInternal(IScope scope, IContent content, - ContentSavingEventArgs saveEventArgs, - int userId = Constants.Security.SuperUserId, bool raiseEvents = true, bool branchOne = false, bool branchRoot = false) + ContentSavingEventArgs saveEventArgs, IReadOnlyCollection allLangs, + int userId = Constants.Security.SuperUserId, + bool raiseEvents = true, bool branchOne = false, bool branchRoot = false) { if (scope == null) throw new ArgumentNullException(nameof(scope)); if (content == null) throw new ArgumentNullException(nameof(content)); @@ -1070,8 +1105,8 @@ namespace Umbraco.Core.Services.Implement if (content.PublishedState != PublishedState.Publishing && content.PublishedState != PublishedState.Unpublishing) content.PublishedState = PublishedState.Publishing; - // state here is either Publishing or Unpublishing - // (even though, Publishing to unpublish a culture may end up unpublishing everything) + // State here is either Publishing or Unpublishing + // Publishing to unpublish a culture may end up unpublishing everything so these flags can be flipped later var publishing = content.PublishedState == PublishedState.Publishing; var unpublishing = content.PublishedState == PublishedState.Unpublishing; @@ -1088,6 +1123,18 @@ namespace Umbraco.Core.Services.Implement var changeType = isNew ? TreeChangeTypes.RefreshNode : TreeChangeTypes.RefreshBranch; var previouslyPublished = content.HasIdentity && content.Published; + //inline method to persist the document with the documentRepository since this logic could be called a couple times below + void SaveDocument(IContent c) + { + // save, always + if (c.HasIdentity == false) + c.CreatorId = userId; + c.WriterId = userId; + + // saving does NOT change the published version, unless PublishedState is Publishing or Unpublishing + _documentRepository.Save(c); + } + if (publishing) { //determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo @@ -1097,11 +1144,25 @@ namespace Umbraco.Core.Services.Implement : null; // ensure that the document can be published, and publish handling events, business rules, etc - publishResult = StrategyCanPublish(scope, content, /*checkPath:*/ (!branchOne || branchRoot), culturesPublishing, culturesUnpublishing, evtMsgs, saveEventArgs); + publishResult = StrategyCanPublish(scope, content, /*checkPath:*/ (!branchOne || branchRoot), culturesPublishing, culturesUnpublishing, evtMsgs, saveEventArgs, allLangs); if (publishResult.Success) { // note: StrategyPublish flips the PublishedState to Publishing! publishResult = StrategyPublish(content, culturesPublishing, culturesUnpublishing, evtMsgs); + + //check if a culture has been unpublished and if there are no cultures left, and then unpublish document as a whole + if (publishResult.Result == PublishResultType.SuccessUnpublishCulture && content.PublishCultureInfos.Count == 0) + { + // This is a special case! We are unpublishing the last culture and to persist that we need to re-publish without any cultures + // so the state needs to remain Publishing to do that. However, we then also need to unpublish the document and to do that + // the state needs to be Unpublishing and it cannot be both. This state is used within the documentRepository to know how to + // persist certain things. So before proceeding below, we need to save the Publishing state to publish no cultures, then we can + // mark the document for Unpublishing. + SaveDocument(content); + + //set the flag to unpublish and continue + unpublishing = content.Published; // if not published yet, nothing to do + } } else { @@ -1162,13 +1223,8 @@ namespace Umbraco.Core.Services.Implement } } - // save, always - if (content.HasIdentity == false) - content.CreatorId = userId; - content.WriterId = userId; - - // saving does NOT change the published version, unless PublishedState is Publishing or Unpublishing - _documentRepository.Save(content); + //Persist the document + SaveDocument(content); // raise the Saved event, always if (raiseEvents) @@ -1186,17 +1242,34 @@ namespace Umbraco.Core.Services.Implement if (culturesUnpublishing != null) { - //If we are here, it means we tried unpublishing a culture but it was mandatory so now everything is unpublished - var langs = string.Join(", ", _languageRepository.GetMany() + // This will mean that that we unpublished a mandatory culture or we unpublished the last culture. + + var langs = string.Join(", ", allLangs .Where(x => culturesUnpublishing.InvariantContains(x.IsoCode)) .Select(x => x.CultureName)); Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); - //log that the whole content item has been unpublished due to mandatory culture unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (mandatory language unpublished)"); - } - else - Audit(AuditType.Unpublish, userId, content.Id); + if (publishResult == null) + throw new PanicException("publishResult == null - should not happen"); + + switch(publishResult.Result) + { + case PublishResultType.FailedPublishMandatoryCultureMissing: + //occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture) + + //log that the whole content item has been unpublished due to mandatory culture unpublished + Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (mandatory language unpublished)"); + return new PublishResult(PublishResultType.SuccessUnpublishMandatoryCulture, evtMsgs, content); + case PublishResultType.SuccessUnpublishCulture: + //occurs when the last culture is unpublished + + Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (last language unpublished)"); + return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, evtMsgs, content); + } + + } + + Audit(AuditType.Unpublish, userId, content.Id); return new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content); } @@ -1211,6 +1284,9 @@ namespace Umbraco.Core.Services.Implement { if (isNew == false && previouslyPublished == false) changeType = TreeChangeTypes.RefreshBranch; // whole branch + else if (isNew == false && previouslyPublished) + changeType = TreeChangeTypes.RefreshNode; // single node + // invalidate the node/branch if (!branchOne) // for branches, handled by SaveAndPublishBranch @@ -1236,7 +1312,7 @@ namespace Umbraco.Core.Services.Implement case PublishResultType.SuccessPublishCulture: if (culturesPublishing != null) { - var langs = string.Join(", ", _languageRepository.GetMany() + var langs = string.Join(", ", allLangs .Where(x => culturesPublishing.InvariantContains(x.IsoCode)) .Select(x => x.CultureName)); Audit(AuditType.PublishVariant, userId, content.Id, $"Published languages: {langs}", langs); @@ -1245,7 +1321,7 @@ namespace Umbraco.Core.Services.Implement case PublishResultType.SuccessUnpublishCulture: if (culturesUnpublishing != null) { - var langs = string.Join(", ", _languageRepository.GetMany() + var langs = string.Join(", ", allLangs .Where(x => culturesUnpublishing.InvariantContains(x.IsoCode)) .Select(x => x.CultureName)); Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); @@ -1259,14 +1335,14 @@ namespace Umbraco.Core.Services.Implement // should not happen if (branchOne && !branchRoot) - throw new Exception("panic"); + throw new PanicException("branchOne && !branchRoot - should not happen"); //if publishing didn't happen or if it has failed, we still need to log which cultures were saved if (!branchOne && (publishResult == null || !publishResult.Success)) { if (culturesChanging != null) { - var langs = string.Join(", ", _languageRepository.GetMany() + var langs = string.Join(", ", allLangs .Where(x => culturesChanging.InvariantContains(x.IsoCode)) .Select(x => x.CultureName)); Audit(AuditType.SaveVariant, userId, content.Id, $"Saved languages: {langs}", langs); @@ -1297,6 +1373,8 @@ namespace Umbraco.Core.Services.Implement { scope.WriteLock(Constants.Locks.ContentTree); + var allLangs = _languageRepository.GetMany().ToList(); + foreach (var d in _documentRepository.GetContentForRelease(date)) { PublishResult result; @@ -1325,7 +1403,7 @@ namespace Umbraco.Core.Services.Implement //publish the culture values and validate the property values, if validation fails, log the invalid properties so the develeper has an idea of what has failed Property[] invalidProperties = null; - var impact = CultureImpact.Explicit(culture, _languageRepository.IsDefault(culture)); + var impact = CultureImpact.Explicit(culture, IsDefaultCulture(allLangs, culture)); var tryPublish = d.PublishCulture(impact) && _propertyValidationService.Value.IsPropertyDataValid(d, out invalidProperties, impact); if (invalidProperties != null && invalidProperties.Length > 0) Logger.Warn("Scheduled publishing will fail for document {DocumentId} and culture {Culture} because of invalid properties {InvalidProperties}", @@ -1340,7 +1418,7 @@ namespace Umbraco.Core.Services.Implement else if (!publishing) result = new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, d); else - result = CommitDocumentChangesInternal(scope, d, saveEventArgs, d.WriterId); + result = CommitDocumentChangesInternal(scope, d, saveEventArgs, allLangs, d.WriterId); if (result.Success == false) @@ -1390,7 +1468,7 @@ namespace Umbraco.Core.Services.Implement d.UnpublishCulture(c); } - result = CommitDocumentChangesInternal(scope, d, saveEventArgs, d.WriterId); + result = CommitDocumentChangesInternal(scope, d, saveEventArgs, allLangs, d.WriterId); if (result.Success == false) Logger.Error(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); yield return result; @@ -1416,7 +1494,7 @@ namespace Umbraco.Core.Services.Implement } // utility 'PublishCultures' func used by SaveAndPublishBranch - private bool SaveAndPublishBranch_PublishCultures(IContent content, HashSet culturesToPublish) + private bool SaveAndPublishBranch_PublishCultures(IContent content, HashSet culturesToPublish, IReadOnlyCollection allLangs) { //TODO: This does not support being able to return invalid property details to bubble up to the UI @@ -1426,7 +1504,7 @@ namespace Umbraco.Core.Services.Implement { return culturesToPublish.All(culture => { - var impact = CultureImpact.Create(culture, _languageRepository.IsDefault(culture), content); + var impact = CultureImpact.Create(culture, IsDefaultCulture(allLangs, culture), content); return content.PublishCulture(impact) && _propertyValidationService.Value.IsPropertyDataValid(content, out _, impact); }); } @@ -1535,7 +1613,7 @@ namespace Umbraco.Core.Services.Implement internal IEnumerable SaveAndPublishBranch(IContent document, bool force, Func> shouldPublish, - Func, bool> publishCultures, + Func, IReadOnlyCollection, bool> publishCultures, int userId = Constants.Security.SuperUserId) { if (shouldPublish == null) throw new ArgumentNullException(nameof(shouldPublish)); @@ -1549,6 +1627,8 @@ namespace Umbraco.Core.Services.Implement { scope.WriteLock(Constants.Locks.ContentTree); + var allLangs = _languageRepository.GetMany().ToList(); + if (!document.HasIdentity) throw new InvalidOperationException("Cannot not branch-publish a new document."); @@ -1557,7 +1637,7 @@ namespace Umbraco.Core.Services.Implement throw new InvalidOperationException("Cannot mix PublishCulture and SaveAndPublishBranch."); // deal with the branch root - if it fails, abort - var result = SaveAndPublishBranchItem(scope, document, shouldPublish, publishCultures, true, publishedDocuments, evtMsgs, userId); + var result = SaveAndPublishBranchItem(scope, document, shouldPublish, publishCultures, true, publishedDocuments, evtMsgs, userId, allLangs); if (result != null) { results.Add(result); @@ -1588,7 +1668,7 @@ namespace Umbraco.Core.Services.Implement } // no need to check path here, parent has to be published here - result = SaveAndPublishBranchItem(scope, d, shouldPublish, publishCultures, false, publishedDocuments, evtMsgs, userId); + result = SaveAndPublishBranchItem(scope, d, shouldPublish, publishCultures, false, publishedDocuments, evtMsgs, userId, allLangs); if (result != null) { results.Add(result); @@ -1620,10 +1700,10 @@ namespace Umbraco.Core.Services.Implement // publishValues: a function publishing values (using the appropriate PublishCulture calls) private PublishResult SaveAndPublishBranchItem(IScope scope, IContent document, Func> shouldPublish, - Func, bool> publishCultures, + Func, IReadOnlyCollection, bool> publishCultures, bool isRoot, ICollection publishedDocuments, - EventMessages evtMsgs, int userId) + EventMessages evtMsgs, int userId, IReadOnlyCollection allLangs) { var culturesToPublish = shouldPublish(document); if (culturesToPublish == null) // null = do not include @@ -1636,13 +1716,13 @@ namespace Umbraco.Core.Services.Implement return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, document); // publish & check if values are valid - if (!publishCultures(document, culturesToPublish)) + if (!publishCultures(document, culturesToPublish, allLangs)) { //TODO: Based on this callback behavior there is no way to know which properties may have been invalid if this failed, see other results of FailedPublishContentInvalid return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, document); } - var result = CommitDocumentChangesInternal(scope, document, saveEventArgs, userId, branchOne: true, branchRoot: isRoot); + var result = CommitDocumentChangesInternal(scope, document, saveEventArgs, allLangs, userId, branchOne: true, branchRoot: isRoot); if (result.Success) publishedDocuments.Add(document); return result; @@ -1768,7 +1848,7 @@ namespace Umbraco.Core.Services.Implement scope.WriteLock(Constants.Locks.ContentTree); var c = _documentRepository.Get(id); - if (c.VersionId != versionId) // don't delete the current version + if (c.VersionId != versionId && c.PublishedVersionId != versionId) // don't delete the current or published version _documentRepository.DeleteVersion(versionId); scope.Events.Dispatch(DeletedVersions, this, new DeleteRevisionsEventArgs(id, false,/* specificVersion:*/ versionId)); @@ -2343,6 +2423,9 @@ namespace Umbraco.Core.Services.Implement _auditRepository.Save(new AuditItem(objectId, type, userId, ObjectTypes.GetName(UmbracoObjectTypes.Document), message, parameters)); } + private bool IsDefaultCulture(IReadOnlyCollection langs, string culture) => langs.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)); + private bool IsMandatoryCulture(IReadOnlyCollection langs, string culture) => langs.Any(x => x.IsMandatory && x.IsoCode.InvariantEquals(culture)); + #endregion #region Event Handlers @@ -2497,7 +2580,9 @@ namespace Umbraco.Core.Services.Implement /// /// /// - private PublishResult StrategyCanPublish(IScope scope, IContent content, bool checkPath, IReadOnlyList culturesPublishing, IReadOnlyCollection culturesUnpublishing, EventMessages evtMsgs, ContentSavingEventArgs savingEventArgs) + private PublishResult StrategyCanPublish(IScope scope, IContent content, bool checkPath, IReadOnlyList culturesPublishing, + IReadOnlyCollection culturesUnpublishing, EventMessages evtMsgs, ContentSavingEventArgs savingEventArgs, + IReadOnlyCollection allLangs) { // raise Publishing event if (scope.Events.DispatchCancelable(Publishing, this, savingEventArgs.ToContentPublishingEventArgs())) @@ -2509,8 +2594,8 @@ namespace Umbraco.Core.Services.Implement var variesByCulture = content.ContentType.VariesByCulture(); var impactsToPublish = culturesPublishing == null - ? new[] {CultureImpact.Invariant} //if it's null it's invariant - : culturesPublishing.Select(x => CultureImpact.Explicit(x, _languageRepository.IsDefault(x))).ToArray(); + ? new[] { CultureImpact.Invariant } //if it's null it's invariant + : culturesPublishing.Select(x => CultureImpact.Explicit(x, allLangs.Any(lang => lang.IsoCode.InvariantEquals(x) && lang.IsMandatory))).ToArray(); // publish the culture(s) if (!impactsToPublish.All(content.PublishCulture)) @@ -2531,11 +2616,17 @@ namespace Umbraco.Core.Services.Implement if (culturesPublishing == null) throw new InvalidOperationException("Internal error, variesByCulture but culturesPublishing is null."); - if (content.Published && culturesPublishing.Count == 0 && culturesUnpublishing.Count == 0) // no published cultures = cannot be published + if (content.Published && culturesPublishing.Count == 0 && culturesUnpublishing.Count == 0) + { + // no published cultures = cannot be published + // This will occur if for example, a culture that is already unpublished is sent to be unpublished again, or vice versa, in that case + // there will be nothing to publish/unpublish. return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); + } + // missing mandatory culture = cannot be published - var mandatoryCultures = _languageRepository.GetMany().Where(x => x.IsMandatory).Select(x => x.IsoCode); + var mandatoryCultures = allLangs.Where(x => x.IsMandatory).Select(x => x.IsoCode); var mandatoryMissing = mandatoryCultures.Any(x => !content.PublishedCultures.Contains(x, StringComparer.OrdinalIgnoreCase)); if (mandatoryMissing) return new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, evtMsgs, content); @@ -2676,6 +2767,7 @@ namespace Umbraco.Core.Services.Implement { var attempt = new PublishResult(PublishResultType.SuccessUnpublish, evtMsgs, content); + //TODO: What is this check?? we just created this attempt and of course it is Success?! if (attempt.Success == false) return attempt; @@ -2793,7 +2885,8 @@ namespace Umbraco.Core.Services.Implement private IContentType GetContentType(IScope scope, string contentTypeAlias) { - if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(contentTypeAlias)); + if (contentTypeAlias == null) throw new ArgumentNullException(nameof(contentTypeAlias)); + if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); scope.ReadLock(Constants.Locks.ContentTypes); @@ -2808,7 +2901,8 @@ namespace Umbraco.Core.Services.Implement private IContentType GetContentType(string contentTypeAlias) { - if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(contentTypeAlias)); + if (contentTypeAlias == null) throw new ArgumentNullException(nameof(contentTypeAlias)); + if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { @@ -2894,13 +2988,27 @@ namespace Umbraco.Core.Services.Implement content.CreatorId = userId; content.WriterId = userId; + IEnumerable cultures = ArrayOfOneNullString; + if (blueprint.CultureInfos.Count > 0) + { + cultures = blueprint.CultureInfos.Values.Select(x => x.Culture); + using (var scope = ScopeProvider.CreateScope()) + { + if (blueprint.CultureInfos.TryGetValue(_languageRepository.GetDefaultIsoCode(), out var defaultCulture)) + { + defaultCulture.Name = name; + } + + scope.Complete(); + } + } + var now = DateTime.Now; - var cultures = blueprint.CultureInfos.Count > 0 ? blueprint.CultureInfos.Values.Select(x => x.Culture) : ArrayOfOneNullString; foreach (var culture in cultures) { foreach (var property in blueprint.Properties) { - var propertyCulture = property.PropertyType.VariesByCulture() ? culture : null; + var propertyCulture = property.PropertyType.VariesByCulture() ? culture : null; content.SetValue(property.Alias, property.GetValue(propertyCulture), propertyCulture); } @@ -2977,7 +3085,7 @@ namespace Umbraco.Core.Services.Implement var version = GetVersion(versionId); //Good ole null checks - if (content == null || version == null) + if (content == null || version == null || content.Trashed) { return new OperationResult(OperationResultType.FailedCannot, evtMsgs); } diff --git a/src/Umbraco.Core/Services/Implement/ContentTypeServiceBaseOfTRepositoryTItemTService.cs b/src/Umbraco.Core/Services/Implement/ContentTypeServiceBaseOfTRepositoryTItemTService.cs index 1f1f0d9ac3..da532e2765 100644 --- a/src/Umbraco.Core/Services/Implement/ContentTypeServiceBaseOfTRepositoryTItemTService.cs +++ b/src/Umbraco.Core/Services/Implement/ContentTypeServiceBaseOfTRepositoryTItemTService.cs @@ -252,12 +252,12 @@ namespace Umbraco.Core.Services.Implement } } - public IEnumerable GetAll(params Guid[] ids) + public IEnumerable GetAll(IEnumerable ids) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(ReadLockIds); - return Repository.GetMany(ids); + return Repository.GetMany(ids.ToArray()); } } @@ -372,6 +372,15 @@ namespace Umbraco.Core.Services.Implement } } + public bool HasContentNodes(int id) + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + scope.ReadLock(ReadLockIds); + return Repository.HasContentNodes(id); + } + } + #endregion #region Save @@ -390,6 +399,11 @@ namespace Umbraco.Core.Services.Implement if (string.IsNullOrWhiteSpace(item.Name)) throw new ArgumentException("Cannot save item with empty name."); + if (item.Name != null && item.Name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } + scope.WriteLock(WriteLockIds); // validate the DAG transform, within the lock @@ -590,10 +604,9 @@ namespace Umbraco.Core.Services.Implement public TItem Copy(TItem original, string alias, string name, TItem parent) { if (original == null) throw new ArgumentNullException(nameof(original)); - if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentNullOrEmptyException(nameof(alias)); - - if (parent != null && parent.HasIdentity == false) - throw new InvalidOperationException("Parent must have an identity."); + if (alias == null) throw new ArgumentNullException(nameof(alias)); + if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(alias)); + if (parent != null && parent.HasIdentity == false) throw new InvalidOperationException("Parent must have an identity."); // this is illegal //var originalb = (ContentTypeCompositionBase)original; diff --git a/src/Umbraco.Core/Services/Implement/DataTypeService.cs b/src/Umbraco.Core/Services/Implement/DataTypeService.cs index 3552b2d8fc..5a93fb91b1 100644 --- a/src/Umbraco.Core/Services/Implement/DataTypeService.cs +++ b/src/Umbraco.Core/Services/Implement/DataTypeService.cs @@ -349,6 +349,11 @@ namespace Umbraco.Core.Services.Implement throw new ArgumentException("Cannot save datatype with empty name."); } + if (dataType.Name != null && dataType.Name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } + _dataTypeRepository.Save(dataType); saveEventArgs.CanCancel = false; @@ -461,6 +466,14 @@ namespace Umbraco.Core.Services.Implement } } + public IReadOnlyDictionary> GetReferences(int id) + { + using (var scope = ScopeProvider.CreateScope(autoComplete:true)) + { + return _dataTypeRepository.FindUsages(id); + } + } + private void Audit(AuditType type, int userId, int objectId) { _auditRepository.Save(new AuditItem(objectId, type, userId, ObjectTypes.GetName(UmbracoObjectTypes.DataType))); diff --git a/src/Umbraco.Core/Services/Implement/EntityXmlSerializer.cs b/src/Umbraco.Core/Services/Implement/EntityXmlSerializer.cs index 863ecc0b1b..5189b3422e 100644 --- a/src/Umbraco.Core/Services/Implement/EntityXmlSerializer.cs +++ b/src/Umbraco.Core/Services/Implement/EntityXmlSerializer.cs @@ -172,7 +172,7 @@ namespace Umbraco.Core.Services.Implement xml.Add(new XAttribute("Id", dataType.EditorAlias)); xml.Add(new XAttribute("Definition", dataType.Key)); xml.Add(new XAttribute("DatabaseType", dataType.DatabaseType.ToString())); - xml.Add(new XAttribute("Configuration", JsonConvert.SerializeObject(dataType.Configuration))); + xml.Add(new XAttribute("Configuration", JsonConvert.SerializeObject(dataType.Configuration, PropertyEditors.ConfigurationEditor.ConfigurationJsonSettings))); var folderNames = string.Empty; if (dataType.Level != 1) @@ -360,7 +360,9 @@ namespace Umbraco.Core.Services.Implement new XElement("Definition", definition.Key), new XElement("Tab", propertyGroup == null ? "" : propertyGroup.Name), new XElement("Mandatory", propertyType.Mandatory.ToString()), + new XElement("MandatoryMessage", propertyType.MandatoryMessage), new XElement("Validation", propertyType.ValidationRegExp), + new XElement("ValidationRegExpMessage", propertyType.ValidationRegExpMessage), new XElement("Description", new XCData(propertyType.Description))); genericProperties.Add(genericProperty); } @@ -437,7 +439,8 @@ namespace Umbraco.Core.Services.Implement new XElement("Description", contentType.Description), new XElement("AllowAtRoot", contentType.AllowedAsRoot.ToString()), new XElement("IsListView", contentType.IsContainer.ToString()), - new XElement("IsElement", contentType.IsElement.ToString())); + new XElement("IsElement", contentType.IsElement.ToString()), + new XElement("Variations", contentType.Variations.ToString())); var masterContentType = contentType.ContentTypeComposition.FirstOrDefault(x => x.Id == contentType.ParentId); if(masterContentType != null) @@ -486,8 +489,11 @@ namespace Umbraco.Core.Services.Implement new XElement("Tab", propertyGroup == null ? "" : propertyGroup.Name), new XElement("SortOrder", propertyType.SortOrder), new XElement("Mandatory", propertyType.Mandatory.ToString()), + propertyType.MandatoryMessage != null ? new XElement("MandatoryMessage", propertyType.MandatoryMessage) : null, propertyType.ValidationRegExp != null ? new XElement("Validation", propertyType.ValidationRegExp) : null, - propertyType.Description != null ? new XElement("Description", new XCData(propertyType.Description)) : null); + propertyType.ValidationRegExpMessage != null ? new XElement("ValidationRegExpMessage", propertyType.ValidationRegExpMessage) : null, + propertyType.Description != null ? new XElement("Description", new XCData(propertyType.Description)) : null, + new XElement("Variations", propertyType.Variations.ToString())); genericProperties.Add(genericProperty); } diff --git a/src/Umbraco.Core/Services/Implement/FileService.cs b/src/Umbraco.Core/Services/Implement/FileService.cs index 79d5b35775..ce8600d798 100644 --- a/src/Umbraco.Core/Services/Implement/FileService.cs +++ b/src/Umbraco.Core/Services/Implement/FileService.cs @@ -358,6 +358,11 @@ namespace Umbraco.Core.Services.Implement { "ContentTypeAlias", contentTypeAlias }, }; + if (contentTypeAlias != null && contentTypeAlias.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } + // check that the template hasn't been created on disk before creating the content type // if it exists, set the new template content to the existing file content string content = GetViewContent(contentTypeAlias); @@ -365,7 +370,10 @@ namespace Umbraco.Core.Services.Implement { template.Content = content; } + + + using (var scope = ScopeProvider.CreateScope()) { var saveEventArgs = new SaveEventArgs(template, true, evtMsgs, additionalData); @@ -397,6 +405,21 @@ namespace Umbraco.Core.Services.Implement /// public ITemplate CreateTemplateWithIdentity(string name, string alias, string content, ITemplate masterTemplate = null, int userId = Constants.Security.SuperUserId) { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Name cannot be empty or contain only white-space characters", nameof(name)); + } + + if (name.Length > 255) + { + throw new ArgumentOutOfRangeException(nameof(name), "Name cannot be more than 255 characters in length."); + } + // file might already be on disk, if so grab the content to avoid overwriting var template = new Template(name, alias) { @@ -531,6 +554,17 @@ namespace Umbraco.Core.Services.Implement /// public void SaveTemplate(ITemplate template, int userId = Constants.Security.SuperUserId) { + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + + if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Length > 255) + { + throw new InvalidOperationException("Name cannot be null, empty, contain only white-space characters or be more than 255 characters in length."); + } + + using (var scope = ScopeProvider.CreateScope()) { if (scope.Events.DispatchCancelable(SavingTemplate, this, new SaveEventArgs(template))) diff --git a/src/Umbraco.Core/Services/Implement/MediaService.cs b/src/Umbraco.Core/Services/Implement/MediaService.cs index ab075c4ade..528d0a0bf9 100644 --- a/src/Umbraco.Core/Services/Implement/MediaService.cs +++ b/src/Umbraco.Core/Services/Implement/MediaService.cs @@ -5,7 +5,6 @@ using System.Globalization; using System.IO; using System.Linq; using Umbraco.Core.Events; -using Umbraco.Core.Exceptions; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Models; @@ -139,6 +138,10 @@ namespace Umbraco.Core.Services.Implement var parent = parentId > 0 ? GetById(parentId) : null; if (parentId > 0 && parent == null) throw new ArgumentException("No media with that id.", nameof(parentId)); + if (name != null && name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } var media = new Models.Media(name, parentId, mediaType); using (var scope = ScopeProvider.CreateScope()) @@ -168,6 +171,10 @@ namespace Umbraco.Core.Services.Implement var mediaType = GetMediaType(mediaTypeAlias); if (mediaType == null) throw new ArgumentException("No media type with that alias.", nameof(mediaTypeAlias)); + if (name != null && name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } var media = new Models.Media(name, -1, mediaType); using (var scope = ScopeProvider.CreateScope()) @@ -202,6 +209,10 @@ namespace Umbraco.Core.Services.Implement var mediaType = GetMediaType(mediaTypeAlias); if (mediaType == null) throw new ArgumentException("No media type with that alias.", nameof(mediaTypeAlias)); // causes rollback + if (name != null && name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } var media = new Models.Media(name, parent, mediaType); CreateMedia(scope, media, parent, userId, false); @@ -648,6 +659,11 @@ namespace Umbraco.Core.Services.Implement if (string.IsNullOrWhiteSpace(media.Name)) throw new ArgumentException("Media has no name.", nameof(media)); + if (media.Name != null && media.Name.Length > 255) + { + throw new InvalidOperationException("Name cannot be more than 255 characters in length."); throw new InvalidOperationException("Name cannot be more than 255 characters in length."); + } + scope.WriteLock(Constants.Locks.MediaTree); if (media.HasIdentity == false) media.CreatorId = userId; @@ -760,7 +776,7 @@ namespace Umbraco.Core.Services.Implement const int pageSize = 500; var page = 0; var total = long.MaxValue; - while(page * pageSize < total) + while (page * pageSize < total) { //get descendants - ordered from deepest to shallowest var descendants = GetPagedDescendants(media.Id, page, pageSize, out total, ordering: Ordering.By("Path", Direction.Descending)); @@ -945,7 +961,7 @@ namespace Umbraco.Core.Services.Implement // if media was trashed, and since we're not moving to the recycle bin, // indicate that the trashed status should be changed to false, else just // leave it unchanged - var trashed = media.Trashed ? false : (bool?) null; + var trashed = media.Trashed ? false : (bool?)null; PerformMoveLocked(media, parentId, parent, userId, moves, trashed); scope.Events.Dispatch(TreeChanged, this, new TreeChange(media, TreeChangeTypes.RefreshBranch).ToEventArgs()); @@ -1009,7 +1025,7 @@ namespace Umbraco.Core.Services.Implement private void PerformMoveMediaLocked(IMedia media, int userId, bool? trash) { - if (trash.HasValue) ((ContentBase) media).Trashed = trash.Value; + if (trash.HasValue) ((ContentBase)media).Trashed = trash.Value; _mediaRepository.Save(media); } @@ -1323,7 +1339,8 @@ namespace Umbraco.Core.Services.Implement private IMediaType GetMediaType(string mediaTypeAlias) { - if (string.IsNullOrWhiteSpace(mediaTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(mediaTypeAlias)); + if (mediaTypeAlias == null) throw new ArgumentNullException(nameof(mediaTypeAlias)); + if (string.IsNullOrWhiteSpace(mediaTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(mediaTypeAlias)); using (var scope = ScopeProvider.CreateScope()) { @@ -1333,7 +1350,7 @@ namespace Umbraco.Core.Services.Implement var mediaType = _mediaTypeRepository.Get(query).FirstOrDefault(); if (mediaType == null) - throw new Exception($"No MediaType matching the passed in Alias: '{mediaTypeAlias}' was found"); // causes rollback // causes rollback + throw new InvalidOperationException($"No media type matched the specified alias '{mediaTypeAlias}'."); scope.Complete(); return mediaType; diff --git a/src/Umbraco.Core/Services/Implement/MemberService.cs b/src/Umbraco.Core/Services/Implement/MemberService.cs index 8c69664712..a64e30495b 100644 --- a/src/Umbraco.Core/Services/Implement/MemberService.cs +++ b/src/Umbraco.Core/Services/Implement/MemberService.cs @@ -816,8 +816,8 @@ namespace Umbraco.Core.Services.Implement { //trimming username and email to make sure we have no trailing space member.Username = member.Username.Trim(); - member.Email = member.Email.Trim(); - + member.Email = member.Email.Trim(); + using (var scope = ScopeProvider.CreateScope()) { var saveEventArgs = new SaveEventArgs(member); @@ -1344,7 +1344,8 @@ namespace Umbraco.Core.Services.Implement private IMemberType GetMemberType(IScope scope, string memberTypeAlias) { - if (string.IsNullOrWhiteSpace(memberTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(memberTypeAlias)); + if (memberTypeAlias == null) throw new ArgumentNullException(nameof(memberTypeAlias)); + if (string.IsNullOrWhiteSpace(memberTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(memberTypeAlias)); scope.ReadLock(Constants.Locks.MemberTypes); @@ -1358,7 +1359,8 @@ namespace Umbraco.Core.Services.Implement private IMemberType GetMemberType(string memberTypeAlias) { - if (string.IsNullOrWhiteSpace(memberTypeAlias)) throw new ArgumentNullOrEmptyException(nameof(memberTypeAlias)); + if (memberTypeAlias == null) throw new ArgumentNullException(nameof(memberTypeAlias)); + if (string.IsNullOrWhiteSpace(memberTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(memberTypeAlias)); using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { diff --git a/src/Umbraco.Core/Services/Implement/PackagingService.cs b/src/Umbraco.Core/Services/Implement/PackagingService.cs index b4dcc70a96..d85c688a0d 100644 --- a/src/Umbraco.Core/Services/Implement/PackagingService.cs +++ b/src/Umbraco.Core/Services/Implement/PackagingService.cs @@ -57,7 +57,7 @@ namespace Umbraco.Core.Services.Implement } catch (HttpRequestException ex) { - throw new ConnectionException("An error occurring downloading the package from " + url, ex); + throw new HttpRequestException("An error occurring downloading the package from " + url, ex); } //successful diff --git a/src/Umbraco.Core/Services/Implement/RelationService.cs b/src/Umbraco.Core/Services/Implement/RelationService.cs index 405c3a2800..4b53709de9 100644 --- a/src/Umbraco.Core/Services/Implement/RelationService.cs +++ b/src/Umbraco.Core/Services/Implement/RelationService.cs @@ -25,11 +25,7 @@ namespace Umbraco.Core.Services.Implement _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); } - /// - /// Gets a by its Id - /// - /// Id of the - /// A object + /// public IRelation GetById(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -38,11 +34,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Gets a by its Id - /// - /// Id of the - /// A object + /// public IRelationType GetRelationTypeById(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -51,11 +43,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Gets a by its Id - /// - /// Id of the - /// A object + /// public IRelationType GetRelationTypeById(Guid id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -64,25 +52,10 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Gets a by its Alias - /// - /// Alias of the - /// A object - public IRelationType GetRelationTypeByAlias(string alias) - { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) - { - var query = Query().Where(x => x.Alias == alias); - return _relationTypeRepository.Get(query).FirstOrDefault(); - } - } + /// + public IRelationType GetRelationTypeByAlias(string alias) => GetRelationType(alias); - /// - /// Gets all objects - /// - /// Optional array of integer ids to return relations for - /// An enumerable list of objects + /// public IEnumerable GetAllRelations(params int[] ids) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -91,21 +64,13 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Gets all objects by their - /// - /// to retrieve Relations for - /// An enumerable list of objects - public IEnumerable GetAllRelationsByRelationType(RelationType relationType) + /// + public IEnumerable GetAllRelationsByRelationType(IRelationType relationType) { return GetAllRelationsByRelationType(relationType.Id); } - /// - /// Gets all objects by their 's Id - /// - /// Id of the to retrieve Relations for - /// An enumerable list of objects + /// public IEnumerable GetAllRelationsByRelationType(int relationTypeId) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -115,11 +80,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Gets all objects - /// - /// Optional array of integer ids to return relationtypes for - /// An enumerable list of objects + /// public IEnumerable GetAllRelationTypes(params int[] ids) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -128,82 +89,65 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Gets a list of objects by their parent Id - /// - /// Id of the parent to retrieve relations for - /// An enumerable list of objects - public IEnumerable GetByParentId(int id) + /// + public IEnumerable GetByParentId(int id) => GetByParentId(id, null); + + /// + public IEnumerable GetByParentId(int id, string relationTypeAlias) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { - var query = Query().Where(x => x.ParentId == id); - return _relationRepository.Get(query); + if (relationTypeAlias.IsNullOrWhiteSpace()) + { + var qry1 = Query().Where(x => x.ParentId == id); + return _relationRepository.Get(qry1); + } + + var relationType = GetRelationType(relationTypeAlias); + if (relationType == null) + return Enumerable.Empty(); + + var qry2 = Query().Where(x => x.ParentId == id && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(qry2); } } - /// - /// Gets a list of objects by their parent entity - /// - /// Parent Entity to retrieve relations for - /// An enumerable list of objects - public IEnumerable GetByParent(IUmbracoEntity parent) - { - return GetByParentId(parent.Id); - } + /// + public IEnumerable GetByParent(IUmbracoEntity parent) => GetByParentId(parent.Id); - /// - /// Gets a list of objects by their parent entity - /// - /// Parent Entity to retrieve relations for - /// Alias of the type of relation to retrieve - /// An enumerable list of objects - public IEnumerable GetByParent(IUmbracoEntity parent, string relationTypeAlias) - { - return GetByParent(parent).Where(relation => relation.RelationType.Alias == relationTypeAlias); - } + /// + public IEnumerable GetByParent(IUmbracoEntity parent, string relationTypeAlias) => GetByParentId(parent.Id, relationTypeAlias); - /// - /// Gets a list of objects by their child Id - /// - /// Id of the child to retrieve relations for - /// An enumerable list of objects - public IEnumerable GetByChildId(int id) + /// + public IEnumerable GetByChildId(int id) => GetByChildId(id, null); + + /// + public IEnumerable GetByChildId(int id, string relationTypeAlias) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { - var query = Query().Where(x => x.ChildId == id); - return _relationRepository.Get(query); + if (relationTypeAlias.IsNullOrWhiteSpace()) + { + var qry1 = Query().Where(x => x.ChildId == id); + return _relationRepository.Get(qry1); + } + + var relationType = GetRelationType(relationTypeAlias); + if (relationType == null) + return Enumerable.Empty(); + + var qry2 = Query().Where(x => x.ChildId == id && x.RelationTypeId == relationType.Id); + return _relationRepository.Get(qry2); } } - /// - /// Gets a list of objects by their child Entity - /// - /// Child Entity to retrieve relations for - /// An enumerable list of objects - public IEnumerable GetByChild(IUmbracoEntity child) - { - return GetByChildId(child.Id); - } + /// + public IEnumerable GetByChild(IUmbracoEntity child) => GetByChildId(child.Id); - /// - /// Gets a list of objects by their child Entity - /// - /// Child Entity to retrieve relations for - /// Alias of the type of relation to retrieve - /// An enumerable list of objects - public IEnumerable GetByChild(IUmbracoEntity child, string relationTypeAlias) - { - return GetByChild(child).Where(relation => relation.RelationType.Alias == relationTypeAlias); - } + /// + public IEnumerable GetByChild(IUmbracoEntity child, string relationTypeAlias) => GetByChildId(child.Id, relationTypeAlias); - /// - /// Gets a list of objects by their child or parent Id. - /// Using this method will get you all relations regards of it being a child or parent relation. - /// - /// Id of the child or parent to retrieve relations for - /// An enumerable list of objects + /// public IEnumerable GetByParentOrChildId(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -217,8 +161,7 @@ namespace Umbraco.Core.Services.Implement { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { - var rtQuery = Query().Where(x => x.Alias == relationTypeAlias); - var relationType = _relationTypeRepository.Get(rtQuery).FirstOrDefault(); + var relationType = GetRelationType(relationTypeAlias); if (relationType == null) return Enumerable.Empty(); @@ -227,16 +170,13 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Gets a list of objects by the Name of the - /// - /// Name of the to retrieve Relations for - /// An enumerable list of objects + /// public IEnumerable GetByRelationTypeName(string relationTypeName) { List relationTypeIds; using (var scope = ScopeProvider.CreateScope(autoComplete: true)) { + //This is a silly query - but i guess it's needed in case someone has more than one relation with the same Name (not alias), odd. var query = Query().Where(x => x.Name == relationTypeName); var relationTypes = _relationTypeRepository.Get(query); relationTypeIds = relationTypes.Select(x => x.Id).ToList(); @@ -247,31 +187,17 @@ namespace Umbraco.Core.Services.Implement : GetRelationsByListOfTypeIds(relationTypeIds); } - /// - /// Gets a list of objects by the Alias of the - /// - /// Alias of the to retrieve Relations for - /// An enumerable list of objects + /// public IEnumerable GetByRelationTypeAlias(string relationTypeAlias) { - List relationTypeIds; - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) - { - var query = Query().Where(x => x.Alias == relationTypeAlias); - var relationTypes = _relationTypeRepository.Get(query); - relationTypeIds = relationTypes.Select(x => x.Id).ToList(); - } - - return relationTypeIds.Count == 0 + var relationType = GetRelationType(relationTypeAlias); + + return relationType == null ? Enumerable.Empty() - : GetRelationsByListOfTypeIds(relationTypeIds); + : GetRelationsByListOfTypeIds(new[] { relationType.Id }); } - /// - /// Gets a list of objects by the Id of the - /// - /// Id of the to retrieve Relations for - /// An enumerable list of objects + /// public IEnumerable GetByRelationTypeId(int relationTypeId) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -281,37 +207,35 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Gets the Child object from a Relation as an - /// - /// Relation to retrieve child object from - /// An + /// + public IEnumerable GetPagedByRelationTypeId(int relationTypeId, long pageIndex, int pageSize, out long totalRecords, Ordering ordering = null) + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + var query = Query().Where(x => x.RelationTypeId == relationTypeId); + return _relationRepository.GetPagedRelationsByQuery(query, pageIndex, pageSize, out totalRecords, ordering); + } + } + + /// public IUmbracoEntity GetChildEntityFromRelation(IRelation relation) { - var objectType = ObjectTypes.GetUmbracoObjectType(relation.RelationType.ChildObjectType); + var objectType = ObjectTypes.GetUmbracoObjectType(relation.ChildObjectType); return _entityService.Get(relation.ChildId, objectType); } - /// - /// Gets the Parent object from a Relation as an - /// - /// Relation to retrieve parent object from - /// An + /// public IUmbracoEntity GetParentEntityFromRelation(IRelation relation) { - var objectType = ObjectTypes.GetUmbracoObjectType(relation.RelationType.ParentObjectType); + var objectType = ObjectTypes.GetUmbracoObjectType(relation.ParentObjectType); return _entityService.Get(relation.ParentId, objectType); } - /// - /// Gets the Parent and Child objects from a Relation as a "/> with . - /// - /// Relation to retrieve parent and child object from - /// Returns a Tuple with Parent (item1) and Child (item2) + /// public Tuple GetEntitiesFromRelation(IRelation relation) { - var childObjectType = ObjectTypes.GetUmbracoObjectType(relation.RelationType.ChildObjectType); - var parentObjectType = ObjectTypes.GetUmbracoObjectType(relation.RelationType.ParentObjectType); + var childObjectType = ObjectTypes.GetUmbracoObjectType(relation.ChildObjectType); + var parentObjectType = ObjectTypes.GetUmbracoObjectType(relation.ParentObjectType); var child = _entityService.Get(relation.ChildId, childObjectType); var parent = _entityService.Get(relation.ParentId, parentObjectType); @@ -319,45 +243,63 @@ namespace Umbraco.Core.Services.Implement return new Tuple(parent, child); } - /// - /// Gets the Child objects from a list of Relations as a list of objects. - /// - /// List of relations to retrieve child objects from - /// An enumerable list of + /// public IEnumerable GetChildEntitiesFromRelations(IEnumerable relations) { - foreach (var relation in relations) + // Trying to avoid full N+1 lookups, so we'll group by the object type and then use the GetAll + // method to lookup batches of entities for each parent object type + + foreach (var groupedRelations in relations.GroupBy(x => ObjectTypes.GetUmbracoObjectType(x.ChildObjectType))) { - var objectType = ObjectTypes.GetUmbracoObjectType(relation.RelationType.ChildObjectType); - yield return _entityService.Get(relation.ChildId, objectType); + var objectType = groupedRelations.Key; + var ids = groupedRelations.Select(x => x.ChildId).ToArray(); + foreach (var e in _entityService.GetAll(objectType, ids)) + yield return e; } } - /// - /// Gets the Parent objects from a list of Relations as a list of objects. - /// - /// List of relations to retrieve parent objects from - /// An enumerable list of + /// public IEnumerable GetParentEntitiesFromRelations(IEnumerable relations) { - foreach (var relation in relations) + // Trying to avoid full N+1 lookups, so we'll group by the object type and then use the GetAll + // method to lookup batches of entities for each parent object type + + foreach (var groupedRelations in relations.GroupBy(x => ObjectTypes.GetUmbracoObjectType(x.ParentObjectType))) { - var objectType = ObjectTypes.GetUmbracoObjectType(relation.RelationType.ParentObjectType); - yield return _entityService.Get(relation.ParentId, objectType); + var objectType = groupedRelations.Key; + var ids = groupedRelations.Select(x => x.ParentId).ToArray(); + foreach (var e in _entityService.GetAll(objectType, ids)) + yield return e; } } - /// - /// Gets the Parent and Child objects from a list of Relations as a list of objects. - /// - /// List of relations to retrieve parent and child objects from - /// An enumerable list of with + /// + public IEnumerable GetPagedParentEntitiesByChildId(int id, long pageIndex, int pageSize, out long totalChildren, params UmbracoObjectTypes[] entityTypes) + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + return _relationRepository.GetPagedParentEntitiesByChildId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); + } + } + + /// + public IEnumerable GetPagedChildEntitiesByParentId(int id, long pageIndex, int pageSize, out long totalChildren, params UmbracoObjectTypes[] entityTypes) + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + return _relationRepository.GetPagedChildEntitiesByParentId(id, pageIndex, pageSize, out totalChildren, entityTypes.Select(x => x.GetGuid()).ToArray()); + } + } + + /// public IEnumerable> GetEntitiesFromRelations(IEnumerable relations) { + //TODO: Argh! N+1 + foreach (var relation in relations) { - var childObjectType = ObjectTypes.GetUmbracoObjectType(relation.RelationType.ChildObjectType); - var parentObjectType = ObjectTypes.GetUmbracoObjectType(relation.RelationType.ParentObjectType); + var childObjectType = ObjectTypes.GetUmbracoObjectType(relation.ChildObjectType); + var parentObjectType = ObjectTypes.GetUmbracoObjectType(relation.ParentObjectType); var child = _entityService.Get(relation.ChildId, childObjectType); var parent = _entityService.Get(relation.ParentId, parentObjectType); @@ -366,19 +308,15 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Relates two objects by their entity Ids. - /// - /// Id of the parent - /// Id of the child - /// The type of relation to create - /// The created + /// public IRelation Relate(int parentId, int childId, IRelationType relationType) { // Ensure that the RelationType has an identity before using it to relate two entities if (relationType.HasIdentity == false) Save(relationType); + //TODO: We don't check if this exists first, it will throw some sort of data integrity exception if it already exists, is that ok? + var relation = new Relation(parentId, childId, relationType); using (var scope = ScopeProvider.CreateScope()) @@ -398,25 +336,13 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Relates two objects that are based on the interface. - /// - /// Parent entity - /// Child entity - /// The type of relation to create - /// The created + /// public IRelation Relate(IUmbracoEntity parent, IUmbracoEntity child, IRelationType relationType) { return Relate(parent.Id, child.Id, relationType); } - /// - /// Relates two objects by their entity Ids. - /// - /// Id of the parent - /// Id of the child - /// Alias of the type of relation to create - /// The created + /// public IRelation Relate(int parentId, int childId, string relationTypeAlias) { var relationType = GetRelationTypeByAlias(relationTypeAlias); @@ -426,13 +352,7 @@ namespace Umbraco.Core.Services.Implement return Relate(parentId, childId, relationType); } - /// - /// Relates two objects that are based on the interface. - /// - /// Parent entity - /// Child entity - /// Alias of the type of relation to create - /// The created + /// public IRelation Relate(IUmbracoEntity parent, IUmbracoEntity child, string relationTypeAlias) { var relationType = GetRelationTypeByAlias(relationTypeAlias); @@ -442,11 +362,7 @@ namespace Umbraco.Core.Services.Implement return Relate(parent.Id, child.Id, relationType); } - /// - /// Checks whether any relations exists for the passed in . - /// - /// to check for relations - /// Returns True if any relations exists for the given , otherwise False + /// public bool HasRelations(IRelationType relationType) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -456,11 +372,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Checks whether any relations exists for the passed in Id. - /// - /// Id of an object to check relations for - /// Returns True if any relations exists with the given Id, otherwise False + /// public bool IsRelated(int id) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -470,12 +382,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Checks whether two items are related - /// - /// Id of the Parent relation - /// Id of the Child relation - /// Returns True if any relations exists with the given Ids, otherwise False + /// public bool AreRelated(int parentId, int childId) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -485,13 +392,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Checks whether two items are related with a given relation type alias - /// - /// Id of the Parent relation - /// Id of the Child relation - /// Alias of the relation type - /// Returns True if any relations exists with the given Ids and relation type, otherwise False + /// public bool AreRelated(int parentId, int childId, string relationTypeAlias) { var relType = GetRelationTypeByAlias(relationTypeAlias); @@ -502,13 +403,7 @@ namespace Umbraco.Core.Services.Implement } - /// - /// Checks whether two items are related with a given relation type - /// - /// Id of the Parent relation - /// Id of the Child relation - /// Type of relation - /// Returns True if any relations exists with the given Ids and relation type, otherwise False + /// public bool AreRelated(int parentId, int childId, IRelationType relationType) { using (var scope = ScopeProvider.CreateScope(autoComplete: true)) @@ -518,34 +413,20 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Checks whether two items are related - /// - /// Parent entity - /// Child entity - /// Returns True if any relations exist between the entities, otherwise False + /// public bool AreRelated(IUmbracoEntity parent, IUmbracoEntity child) { return AreRelated(parent.Id, child.Id); } - /// - /// Checks whether two items are related - /// - /// Parent entity - /// Child entity - /// Alias of the type of relation to create - /// Returns True if any relations exist between the entities, otherwise False + /// public bool AreRelated(IUmbracoEntity parent, IUmbracoEntity child, string relationTypeAlias) { return AreRelated(parent.Id, child.Id, relationTypeAlias); } - /// - /// Saves a - /// - /// Relation to save + /// public void Save(IRelation relation) { using (var scope = ScopeProvider.CreateScope()) @@ -564,10 +445,25 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Saves a - /// - /// RelationType to Save + public void Save(IEnumerable relations) + { + using (var scope = ScopeProvider.CreateScope()) + { + var saveEventArgs = new SaveEventArgs(relations); + if (scope.Events.DispatchCancelable(SavingRelation, this, saveEventArgs)) + { + scope.Complete(); + return; + } + + _relationRepository.Save(relations); + scope.Complete(); + saveEventArgs.CanCancel = false; + scope.Events.Dispatch(SavedRelation, this, saveEventArgs); + } + } + + /// public void Save(IRelationType relationType) { using (var scope = ScopeProvider.CreateScope()) @@ -586,10 +482,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Deletes a - /// - /// Relation to Delete + /// public void Delete(IRelation relation) { using (var scope = ScopeProvider.CreateScope()) @@ -608,10 +501,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Deletes a - /// - /// RelationType to Delete + /// public void Delete(IRelationType relationType) { using (var scope = ScopeProvider.CreateScope()) @@ -630,10 +520,7 @@ namespace Umbraco.Core.Services.Implement } } - /// - /// Deletes all objects based on the passed in - /// - /// to Delete Relations for + /// public void DeleteRelationsOfType(IRelationType relationType) { var relations = new List(); @@ -642,6 +529,8 @@ namespace Umbraco.Core.Services.Implement var query = Query().Where(x => x.RelationTypeId == relationType.Id); relations.AddRange(_relationRepository.Get(query).ToList()); + //TODO: N+1, we should be able to do this in a single call + foreach (var relation in relations) _relationRepository.Delete(relation); @@ -653,6 +542,15 @@ namespace Umbraco.Core.Services.Implement #region Private Methods + private IRelationType GetRelationType(string relationTypeAlias) + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + var query = Query().Where(x => x.Alias == relationTypeAlias); + return _relationTypeRepository.Get(query).FirstOrDefault(); + } + } + private IEnumerable GetRelationsByListOfTypeIds(IEnumerable relationTypeIds) { var relations = new List(); diff --git a/src/Umbraco.Core/Services/Implement/UserService.cs b/src/Umbraco.Core/Services/Implement/UserService.cs index 0ea77dedcc..363bc72bc3 100644 --- a/src/Umbraco.Core/Services/Implement/UserService.cs +++ b/src/Umbraco.Core/Services/Implement/UserService.cs @@ -1,15 +1,11 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Data.Common; -using System.Data.SqlClient; -using System.Data.SqlServerCe; using System.Globalization; using System.Linq; using System.Linq.Expressions; using Umbraco.Core.Configuration; using Umbraco.Core.Events; -using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Models.Membership; using Umbraco.Core.Persistence.DatabaseModelDefinitions; @@ -17,7 +13,6 @@ using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.Repositories; using Umbraco.Core.Persistence.Repositories.Implement; using Umbraco.Core.Scoping; -using Umbraco.Core.Security; namespace Umbraco.Core.Services.Implement { @@ -107,7 +102,8 @@ namespace Umbraco.Core.Services.Implement /// private IUser CreateUserWithIdentity(string username, string email, string passwordValue, bool isApproved = true) { - if (string.IsNullOrWhiteSpace(username)) throw new ArgumentNullOrEmptyException(nameof(username)); + if (username == null) throw new ArgumentNullException(nameof(username)); + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(username)); // TODO: PUT lock here!! diff --git a/src/Umbraco.Core/Services/PropertyValidationService.cs b/src/Umbraco.Core/Services/PropertyValidationService.cs index b846095bd1..a037a83920 100644 --- a/src/Umbraco.Core/Services/PropertyValidationService.cs +++ b/src/Umbraco.Core/Services/PropertyValidationService.cs @@ -131,6 +131,12 @@ namespace Umbraco.Core.Services private bool IsPropertyValueValid(PropertyType propertyType, object value) { var editor = _propertyEditors[propertyType.PropertyEditorAlias]; + if (editor == null) + { + // nothing much we can do validation wise if the property editor has been removed. + // the property will be displayed as a label, so flagging it as invalid would be pointless. + return true; + } var configuration = _dataTypeService.GetDataType(propertyType.DataTypeId).Configuration; var valueEditor = editor.GetValueEditor(configuration); return !valueEditor.Validate(value, propertyType.Mandatory, propertyType.ValidationRegExp).Any(); diff --git a/src/Umbraco.Core/Services/PublishResultType.cs b/src/Umbraco.Core/Services/PublishResultType.cs index 1a2b52f9c9..66c1e38267 100644 --- a/src/Umbraco.Core/Services/PublishResultType.cs +++ b/src/Umbraco.Core/Services/PublishResultType.cs @@ -49,6 +49,11 @@ /// SuccessUnpublishMandatoryCulture = 6, + /// + /// The specified document culture was unpublished, and was the last published culture in the document, therefore the document itself was unpublished. + /// + SuccessUnpublishLastCulture = 8, + #endregion #region Success - Mixed @@ -113,9 +118,9 @@ FailedPublishContentInvalid = FailedPublish | 8, /// - /// The document could not be published because it has no publishing flags or values. + /// The document could not be published because it has no publishing flags or values or if its a variant document, no cultures were specified to be published. /// - FailedPublishNothingToPublish = FailedPublish | 9, // TODO: in ContentService.StrategyCanPublish - weird + FailedPublishNothingToPublish = FailedPublish | 9, /// /// The document could not be published because some mandatory cultures are missing. diff --git a/src/Umbraco.Core/Services/UserServiceExtensions.cs b/src/Umbraco.Core/Services/UserServiceExtensions.cs index 31c446352e..82cab07b25 100644 --- a/src/Umbraco.Core/Services/UserServiceExtensions.cs +++ b/src/Umbraco.Core/Services/UserServiceExtensions.cs @@ -104,5 +104,17 @@ namespace Umbraco.Core.Services return found; } + + /// + /// Gets the concrete assigned permissions for the provided user and node + /// + /// + /// + /// + internal static string[] GetAssignedPermissions(this IUserService userService, IUser user, int nodeId) + { + var permissionCollection = userService.GetPermissions(user, nodeId); + return permissionCollection.SelectMany(c => c.AssignedPermissions).Distinct().ToArray(); + } } } diff --git a/src/Umbraco.Core/StringExtensions.cs b/src/Umbraco.Core/StringExtensions.cs index 4df1105bf7..4451fdbba7 100644 --- a/src/Umbraco.Core/StringExtensions.cs +++ b/src/Umbraco.Core/StringExtensions.cs @@ -10,9 +10,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Web.Security; using Newtonsoft.Json; -using Umbraco.Core.Configuration; using Umbraco.Core.Composing; -using Umbraco.Core.Exceptions; using Umbraco.Core.IO; using Umbraco.Core.Strings; @@ -724,67 +722,56 @@ namespace Umbraco.Core /// /// Generates a hash of a string based on the FIPS compliance setting. /// - /// Refers to itself - /// The hashed string + /// The to hash. + /// + /// The hashed string. + /// public static string GenerateHash(this string str) { - return CryptoConfig.AllowOnlyFipsAlgorithms - ? str.ToSHA1() - : str.ToMd5(); + return str.GenerateHash(CryptoConfig.AllowOnlyFipsAlgorithms ? "SHA1" : "MD5"); } /// - /// Converts the string to MD5 + /// Generate a hash of a string based on the specified hash algorithm. /// - /// Refers to itself - /// The MD5 hashed string - [Obsolete("Please use the GenerateHash method instead. This may be removed in future versions")] - internal static string ToMd5(this string stringToConvert) + /// The hash algorithm implementation to use. + /// The to hash. + /// + /// The hashed string. + /// + internal static string GenerateHash(this string str) + where T : HashAlgorithm { - return stringToConvert.GenerateHash("MD5"); + return str.GenerateHash(typeof(T).FullName); } /// - /// Converts the string to SHA1 + /// Generate a hash of a string based on the specified . /// - /// refers to itself - /// The SHA1 hashed string - [Obsolete("Please use the GenerateHash method instead. This may be removed in future versions")] - internal static string ToSHA1(this string stringToConvert) + /// The to hash. + /// The hash algorithm implementation to use. + /// + /// The hashed string. + /// + /// No hashing type found by name . + /// + internal static string GenerateHash(this string str, string hashType) { - return stringToConvert.GenerateHash("SHA1"); - } - - /// Generate a hash of a string based on the hashType passed in - /// - /// Refers to itself - /// String with the hash type. See remarks section of the CryptoConfig Class in MSDN docs for a list of possible values. - /// The hashed string - private static string GenerateHash(this string str, string hashType) - { - //create an instance of the correct hashing provider based on the type passed in var hasher = HashAlgorithm.Create(hashType); - if (hasher == null) throw new InvalidOperationException("No hashing type found by name " + hashType); + if (hasher == null) throw new InvalidOperationException($"No hashing type found by name {hashType}."); + using (hasher) { - //convert our string into byte array var byteArray = Encoding.UTF8.GetBytes(str); - - //get the hashed values created by our selected provider var hashedByteArray = hasher.ComputeHash(byteArray); - //create a StringBuilder object - var stringBuilder = new StringBuilder(); - - //loop to each byte + var sb = new StringBuilder(); foreach (var b in hashedByteArray) { - //append it to our StringBuilder - stringBuilder.Append(b.ToString("x2")); + sb.Append(b.ToString("x2")); } - //return the hashed value - return stringBuilder.ToString(); + return sb.ToString(); } } @@ -1102,7 +1089,8 @@ namespace Umbraco.Core /// The safe url segment. public static string ToUrlSegment(this string text) { - if (string.IsNullOrWhiteSpace(text)) throw new ArgumentNullOrEmptyException(nameof(text)); + if (text == null) throw new ArgumentNullException(nameof(text)); + if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(text)); return Current.ShortStringHelper.CleanStringForUrlSegment(text); } @@ -1115,7 +1103,8 @@ namespace Umbraco.Core /// The safe url segment. public static string ToUrlSegment(this string text, string culture) { - if (string.IsNullOrWhiteSpace(text)) throw new ArgumentNullOrEmptyException(nameof(text)); + if (text == null) throw new ArgumentNullException(nameof(text)); + if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(text)); return Current.ShortStringHelper.CleanStringForUrlSegment(text, culture); } diff --git a/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs b/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs index 9cd4a58c65..2a558f85aa 100644 --- a/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs +++ b/src/Umbraco.Core/Sync/ApplicationUrlHelper.cs @@ -97,8 +97,7 @@ namespace Umbraco.Core.Sync ? ":" + request.ServerVariables["SERVER_PORT"] : ""; - var useSsl = globalSettings.UseHttps || port == "443"; - var ssl = useSsl ? "s" : ""; // force, whatever the first request + var ssl = globalSettings.UseHttps ? "s" : ""; // force, whatever the first request var url = "http" + ssl + "://" + request.ServerVariables["SERVER_NAME"] + port + IOHelper.ResolveUrl(SystemDirectories.Umbraco); return url.TrimEnd('/'); diff --git a/src/Umbraco.Core/AsyncLock.cs b/src/Umbraco.Core/SystemLock.cs similarity index 77% rename from src/Umbraco.Core/AsyncLock.cs rename to src/Umbraco.Core/SystemLock.cs index 6dd866705e..4eaae7082b 100644 --- a/src/Umbraco.Core/AsyncLock.cs +++ b/src/Umbraco.Core/SystemLock.cs @@ -21,18 +21,18 @@ namespace Umbraco.Core // been closed, the Semaphore system object is destroyed - so in any case // an iisreset should clean up everything // - internal class AsyncLock + internal class SystemLock { private readonly SemaphoreSlim _semaphore; private readonly Semaphore _semaphore2; private readonly IDisposable _releaser; private readonly Task _releaserTask; - public AsyncLock() - : this (null) + public SystemLock() + : this(null) { } - public AsyncLock(string name) + public SystemLock(string name) { // WaitOne() waits until count > 0 then decrements count // Release() increments count @@ -67,35 +67,6 @@ namespace Umbraco.Core : new NamedSemaphoreReleaser(_semaphore2); } - //NOTE: We don't use the "Async" part of this lock at all - //TODO: Remove this and rename this class something like SystemWideLock, then we can re-instate this logic if we ever need an Async lock again - - //public Task LockAsync() - //{ - // var wait = _semaphore != null - // ? _semaphore.WaitAsync() - // : _semaphore2.WaitOneAsync(); - - // return wait.IsCompleted - // ? _releaserTask ?? Task.FromResult(CreateReleaser()) // anonymous vs named - // : wait.ContinueWith((_, state) => (((AsyncLock) state).CreateReleaser()), - // this, CancellationToken.None, - // TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - //} - - //public Task LockAsync(int millisecondsTimeout) - //{ - // var wait = _semaphore != null - // ? _semaphore.WaitAsync(millisecondsTimeout) - // : _semaphore2.WaitOneAsync(millisecondsTimeout); - - // return wait.IsCompleted - // ? _releaserTask ?? Task.FromResult(CreateReleaser()) // anonymous vs named - // : wait.ContinueWith((_, state) => (((AsyncLock)state).CreateReleaser()), - // this, CancellationToken.None, - // TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - //} - public IDisposable Lock() { if (_semaphore != null) @@ -121,14 +92,18 @@ namespace Umbraco.Core private class NamedSemaphoreReleaser : CriticalFinalizerObject, IDisposable { private readonly Semaphore _semaphore; - private GCHandle _handle; internal NamedSemaphoreReleaser(Semaphore semaphore) { _semaphore = semaphore; - _handle = GCHandle.Alloc(_semaphore); } + #region IDisposable Support + + // This code added to correctly implement the disposable pattern. + + private bool disposedValue = false; // To detect redundant calls + public void Dispose() { Dispose(true); @@ -137,10 +112,22 @@ namespace Umbraco.Core private void Dispose(bool disposing) { - // critical - _handle.Free(); - _semaphore.Release(); - _semaphore.Dispose(); + if (!disposedValue) + { + try + { + _semaphore.Release(); + } + finally + { + try + { + _semaphore.Dispose(); + } + catch { } + } + disposedValue = true; + } } // we WANT to release the semaphore because it's a system object, ie a critical @@ -171,6 +158,9 @@ namespace Umbraco.Core // we do NOT want the finalizer to throw - never ever } } + + #endregion + } private class SemaphoreSlimReleaser : IDisposable diff --git a/src/Umbraco.Core/Udi.cs b/src/Umbraco.Core/Udi.cs index c7297b8c09..ea3ec0ed2d 100644 --- a/src/Umbraco.Core/Udi.cs +++ b/src/Umbraco.Core/Udi.cs @@ -233,7 +233,7 @@ namespace Umbraco.Core // just pick every service connectors - just making sure that not two of them // would register the same entity type, with different udi types (would not make // much sense anyways). - var connectors = Current.TypeLoader.GetTypes(); + var connectors = Current.HasFactory ? (Current.TypeLoader?.GetTypes() ?? Enumerable.Empty()) : Enumerable.Empty(); var result = new Dictionary(); foreach (var connector in connectors) { @@ -368,7 +368,7 @@ namespace Umbraco.Core return (udi1 == udi2) == false; } - private class UnknownTypeUdi : Udi + internal class UnknownTypeUdi : Udi { private UnknownTypeUdi() : base("unknown", "umb://unknown/") diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 6adce1944f..a8e3fc2988 100755 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -10,6 +10,7 @@ Umbraco.Core ..\ + $(AdditionalFileItemNames);Content true @@ -60,6 +61,11 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + 3.3.0 + runtime; build; native; contentfiles; analyzers + all + 1.3.0 @@ -122,7 +128,12 @@ --> - + + + + + + @@ -204,6 +215,7 @@ + @@ -234,6 +246,7 @@ + @@ -243,6 +256,11 @@ + + + + + @@ -250,6 +268,7 @@ + @@ -261,10 +280,20 @@ + + + + + + + + + + @@ -336,6 +365,7 @@ + @@ -345,6 +375,7 @@ + @@ -383,7 +414,7 @@ - + @@ -666,7 +697,6 @@ - @@ -715,7 +745,7 @@ - + @@ -834,7 +864,7 @@ - + @@ -979,7 +1009,6 @@ - @@ -1052,7 +1081,6 @@ - @@ -1514,7 +1542,7 @@ - + diff --git a/src/Umbraco.Core/Xml/DynamicContext.cs b/src/Umbraco.Core/Xml/DynamicContext.cs index d39c4e133b..e5b90a1622 100644 --- a/src/Umbraco.Core/Xml/DynamicContext.cs +++ b/src/Umbraco.Core/Xml/DynamicContext.cs @@ -236,7 +236,7 @@ namespace Umbraco.Core.Xml _name = name; _value = value; - if (value is String) + if (value is string) _type = XPathResultType.String; else if (value is bool) _type = XPathResultType.Boolean; diff --git a/src/Umbraco.Core/Xml/UmbracoXPathPathSyntaxParser.cs b/src/Umbraco.Core/Xml/UmbracoXPathPathSyntaxParser.cs index b31fa6a8df..24fb070663 100644 --- a/src/Umbraco.Core/Xml/UmbracoXPathPathSyntaxParser.cs +++ b/src/Umbraco.Core/Xml/UmbracoXPathPathSyntaxParser.cs @@ -1,9 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Umbraco.Core.Exceptions; namespace Umbraco.Core.Xml { @@ -37,7 +34,8 @@ namespace Umbraco.Core.Xml // allowed 'inline', not just at the beginning... whether or not we want to support that is up // for discussion. - if (string.IsNullOrWhiteSpace(xpathExpression)) throw new ArgumentNullOrEmptyException(nameof(xpathExpression)); + if (xpathExpression == null) throw new ArgumentNullException(nameof(xpathExpression)); + if (string.IsNullOrWhiteSpace(xpathExpression)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(xpathExpression)); if (getPath == null) throw new ArgumentNullException(nameof(getPath)); if (publishedContentExists == null) throw new ArgumentNullException(nameof(publishedContentExists)); diff --git a/src/Umbraco.Core/Xml/XmlHelper.cs b/src/Umbraco.Core/Xml/XmlHelper.cs index 3fd8dcaea5..2dd955086d 100644 --- a/src/Umbraco.Core/Xml/XmlHelper.cs +++ b/src/Umbraco.Core/Xml/XmlHelper.cs @@ -4,9 +4,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Xml; -using System.Xml.Linq; using System.Xml.XPath; -using Umbraco.Core.Exceptions; using Umbraco.Core.IO; namespace Umbraco.Core.Xml @@ -25,9 +23,10 @@ namespace Umbraco.Core.Xml /// public static void SetAttribute(XmlDocument xml, XmlNode n, string name, string value) { - if (xml == null) throw new ArgumentNullException("xml"); - if (n == null) throw new ArgumentNullException("n"); - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (xml == null) throw new ArgumentNullException(nameof(xml)); + if (n == null) throw new ArgumentNullException(nameof(n)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); if (n.Attributes == null) { @@ -229,8 +228,9 @@ namespace Umbraco.Core.Xml /// a XmlAttribute public static XmlAttribute AddAttribute(XmlDocument xd, string name, string value) { - if (xd == null) throw new ArgumentNullException("xd"); - if (string.IsNullOrEmpty(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (xd == null) throw new ArgumentNullException(nameof(xd)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); var temp = xd.CreateAttribute(name); temp.Value = value; @@ -246,8 +246,9 @@ namespace Umbraco.Core.Xml /// a XmlNode public static XmlNode AddTextNode(XmlDocument xd, string name, string value) { - if (xd == null) throw new ArgumentNullException("xd"); - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (xd == null) throw new ArgumentNullException(nameof(xd)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); var temp = xd.CreateNode(XmlNodeType.Element, name, ""); temp.AppendChild(xd.CreateTextNode(value)); @@ -264,9 +265,10 @@ namespace Umbraco.Core.Xml /// a XmlNode public static XmlNode SetTextNode(XmlDocument xd, XmlNode parent, string name, string value) { - if (xd == null) throw new ArgumentNullException("xd"); - if (parent == null) throw new ArgumentNullException("parent"); - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (xd == null) throw new ArgumentNullException(nameof(xd)); + if (parent == null) throw new ArgumentNullException(nameof(parent)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); var child = parent.SelectSingleNode(name); if (child != null) @@ -289,7 +291,8 @@ namespace Umbraco.Core.Xml { if (xd == null) throw new ArgumentNullException(nameof(xd)); if (parent == null) throw new ArgumentNullException(nameof(parent)); - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); var child = parent.SelectSingleNode(name) ?? xd.CreateNode(XmlNodeType.Element, name, ""); child.InnerXml = value; @@ -305,8 +308,9 @@ namespace Umbraco.Core.Xml /// A XmlNode public static XmlNode AddCDataNode(XmlDocument xd, string name, string value) { - if (xd == null) throw new ArgumentNullException("xd"); - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (xd == null) throw new ArgumentNullException(nameof(xd)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); var temp = xd.CreateNode(XmlNodeType.Element, name, ""); temp.AppendChild(xd.CreateCDataSection(value)); @@ -323,9 +327,10 @@ namespace Umbraco.Core.Xml /// a XmlNode public static XmlNode SetCDataNode(XmlDocument xd, XmlNode parent, string name, string value) { - if (xd == null) throw new ArgumentNullException("xd"); - if (parent == null) throw new ArgumentNullException("parent"); - if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullOrEmptyException(nameof(name)); + if (xd == null) throw new ArgumentNullException(nameof(xd)); + if (parent == null) throw new ArgumentNullException(nameof(parent)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(name)); var child = parent.SelectSingleNode(name); if (child != null) diff --git a/src/Umbraco.Examine/ContentIndexPopulator.cs b/src/Umbraco.Examine/ContentIndexPopulator.cs index 51b9de4a0b..99ff4d7f87 100644 --- a/src/Umbraco.Examine/ContentIndexPopulator.cs +++ b/src/Umbraco.Examine/ContentIndexPopulator.cs @@ -15,7 +15,7 @@ namespace Umbraco.Examine /// /// Performs the data lookups required to rebuild a content index /// - public class ContentIndexPopulator : IndexPopulator + public class ContentIndexPopulator : IndexPopulator { private readonly IContentService _contentService; private readonly IValueSetBuilder _contentValueSetBuilder; @@ -36,7 +36,7 @@ namespace Umbraco.Examine /// public ContentIndexPopulator(IContentService contentService, ISqlContext sqlContext, IContentValueSetBuilder contentValueSetBuilder) : this(false, null, contentService, sqlContext, contentValueSetBuilder) - { + { } /// @@ -52,7 +52,7 @@ namespace Umbraco.Examine if (sqlContext == null) throw new ArgumentNullException(nameof(sqlContext)); _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); _contentValueSetBuilder = contentValueSetBuilder ?? throw new ArgumentNullException(nameof(contentValueSetBuilder)); - if (_publishedQuery != null) + if (_publishedQuery == null) _publishedQuery = sqlContext.Query().Where(x => x.Published); _publishedValuesOnly = publishedValuesOnly; _parentId = parentId; diff --git a/src/Umbraco.Examine/ContentValueSetBuilder.cs b/src/Umbraco.Examine/ContentValueSetBuilder.cs index 44cef08813..9cbc311639 100644 --- a/src/Umbraco.Examine/ContentValueSetBuilder.cs +++ b/src/Umbraco.Examine/ContentValueSetBuilder.cs @@ -54,7 +54,7 @@ namespace Umbraco.Examine {"updateDate", new object[] {c.UpdateDate}}, //Always add invariant updateDate {"nodeName", (PublishedValuesOnly //Always add invariant nodeName ? c.PublishName?.Yield() - : c?.Name.Yield()) ?? Enumerable.Empty()}, + : c.Name?.Yield()) ?? Enumerable.Empty()}, {"urlName", urlValue?.Yield() ?? Enumerable.Empty()}, //Always add invariant urlName {"path", c.Path?.Yield() ?? Enumerable.Empty()}, {"nodeType", c.ContentType.Id.ToString().Yield() ?? Enumerable.Empty()}, diff --git a/src/Umbraco.Examine/ExamineExtensions.cs b/src/Umbraco.Examine/ExamineExtensions.cs index d97278f31c..d231a86f69 100644 --- a/src/Umbraco.Examine/ExamineExtensions.cs +++ b/src/Umbraco.Examine/ExamineExtensions.cs @@ -12,6 +12,7 @@ using Lucene.Net.Store; using Umbraco.Core; using Version = Lucene.Net.Util.Version; using Umbraco.Core.Logging; +using System.Threading; namespace Umbraco.Examine { @@ -28,6 +29,29 @@ namespace Umbraco.Examine /// internal static readonly Regex CultureIsoCodeFieldNameMatchExpression = new Regex("^([_\\w]+)_([a-z]{2}-[a-z0-9]{2,4})$", RegexOptions.Compiled); + private static bool _isConfigured = false; + private static object _configuredInit = null; + private static object _isConfiguredLocker = new object(); + + /// + /// Called on startup to configure each index. + /// + /// + /// Configures and unlocks all Lucene based indexes registered with the . + /// + internal static void ConfigureIndexes(this IExamineManager examineManager, IMainDom mainDom, ILogger logger) + { + LazyInitializer.EnsureInitialized( + ref _configuredInit, + ref _isConfigured, + ref _isConfiguredLocker, + () => + { + examineManager.ConfigureLuceneIndexes(logger, !mainDom.IsMainDom); + return null; + }); + } + //TODO: We need a public method here to just match a field name against CultureIsoCodeFieldNameMatchExpression /// diff --git a/src/Umbraco.Examine/IUmbracoContentIndex.cs b/src/Umbraco.Examine/IUmbracoContentIndex.cs new file mode 100644 index 0000000000..3181ff663e --- /dev/null +++ b/src/Umbraco.Examine/IUmbracoContentIndex.cs @@ -0,0 +1,9 @@ +using Examine; + +namespace Umbraco.Examine +{ + public interface IUmbracoContentIndex : IIndex + { + + } +} diff --git a/src/Umbraco.Examine/IUmbracoIndexConfig.cs b/src/Umbraco.Examine/IUmbracoIndexConfig.cs new file mode 100644 index 0000000000..02c6c51d0c --- /dev/null +++ b/src/Umbraco.Examine/IUmbracoIndexConfig.cs @@ -0,0 +1,12 @@ +using Examine; + +namespace Umbraco.Examine +{ + public interface IUmbracoIndexConfig + { + IContentValueSetValidator GetContentValueSetValidator(); + IContentValueSetValidator GetPublishedContentValueSetValidator(); + IValueSetValidator GetMemberValueSetValidator(); + + } +} diff --git a/src/Umbraco.Examine/IUmbracoMemberIndex.cs b/src/Umbraco.Examine/IUmbracoMemberIndex.cs new file mode 100644 index 0000000000..b1f325b2e9 --- /dev/null +++ b/src/Umbraco.Examine/IUmbracoMemberIndex.cs @@ -0,0 +1,9 @@ +using Examine; + +namespace Umbraco.Examine +{ + public interface IUmbracoMemberIndex : IIndex + { + + } +} diff --git a/src/Umbraco.Examine/IndexRebuilder.cs b/src/Umbraco.Examine/IndexRebuilder.cs index 43c309b9c5..786aecac71 100644 --- a/src/Umbraco.Examine/IndexRebuilder.cs +++ b/src/Umbraco.Examine/IndexRebuilder.cs @@ -5,7 +5,8 @@ using System.Threading.Tasks; using Examine; namespace Umbraco.Examine -{ +{ + /// /// Utility to rebuild all indexes ensuring minimal data queries /// diff --git a/src/Umbraco.Examine/LuceneIndexDiagnostics.cs b/src/Umbraco.Examine/LuceneIndexDiagnostics.cs new file mode 100644 index 0000000000..96363904b4 --- /dev/null +++ b/src/Umbraco.Examine/LuceneIndexDiagnostics.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using Examine.LuceneEngine.Providers; +using Umbraco.Core; +using Umbraco.Core.Logging; +using Lucene.Net.Store; +using Umbraco.Core.IO; +using System.Linq; + +namespace Umbraco.Examine +{ + public class LuceneIndexDiagnostics : IIndexDiagnostics + { + public LuceneIndexDiagnostics(LuceneIndex index, ILogger logger) + { + Index = index; + Logger = logger; + } + + public LuceneIndex Index { get; } + public ILogger Logger { get; } + + public int DocumentCount + { + get + { + try + { + return Index.GetIndexDocumentCount(); + } + catch (AlreadyClosedException) + { + Logger.Warn(typeof(UmbracoContentIndex), "Cannot get GetIndexDocumentCount, the writer is already closed"); + return 0; + } + } + } + + public int FieldCount + { + get + { + try + { + return Index.GetIndexFieldCount(); + } + catch (AlreadyClosedException) + { + Logger.Warn(typeof(UmbracoContentIndex), "Cannot get GetIndexFieldCount, the writer is already closed"); + return 0; + } + } + } + + public Attempt IsHealthy() + { + var isHealthy = Index.IsHealthy(out var indexError); + return isHealthy ? Attempt.Succeed() : Attempt.Fail(indexError.Message); + } + + public virtual IReadOnlyDictionary Metadata + { + get + { + var luceneDir = Index.GetLuceneDirectory(); + var d = new Dictionary + { + [nameof(UmbracoExamineIndex.CommitCount)] = Index.CommitCount, + [nameof(UmbracoExamineIndex.DefaultAnalyzer)] = Index.DefaultAnalyzer.GetType().Name, + ["LuceneDirectory"] = luceneDir.GetType().Name + }; + + if (luceneDir is FSDirectory fsDir) + { + d[nameof(UmbracoExamineIndex.LuceneIndexFolder)] = fsDir.Directory.ToString().ToLowerInvariant().TrimStart(IOHelper.MapPath(SystemDirectories.Root).ToLowerInvariant()).Replace("\\", "/").EnsureStartsWith('/'); + } + + return d; + } + } + + + } +} diff --git a/src/Umbraco.Examine/MediaIndexPopulator.cs b/src/Umbraco.Examine/MediaIndexPopulator.cs index 6dadcbe4b3..1f5b11e54f 100644 --- a/src/Umbraco.Examine/MediaIndexPopulator.cs +++ b/src/Umbraco.Examine/MediaIndexPopulator.cs @@ -10,7 +10,7 @@ namespace Umbraco.Examine /// /// Performs the data lookups required to rebuild a media index /// - public class MediaIndexPopulator : IndexPopulator + public class MediaIndexPopulator : IndexPopulator { private readonly int? _parentId; private readonly IMediaService _mediaService; @@ -69,6 +69,6 @@ namespace Umbraco.Examine pageIndex++; } while (media.Length == pageSize); } - + } } diff --git a/src/Umbraco.Examine/MediaValueSetBuilder.cs b/src/Umbraco.Examine/MediaValueSetBuilder.cs index 3839d008b3..03e7f4944b 100644 --- a/src/Umbraco.Examine/MediaValueSetBuilder.cs +++ b/src/Umbraco.Examine/MediaValueSetBuilder.cs @@ -1,9 +1,13 @@ -using Examine; +using System; +using Examine; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; using Umbraco.Core; +using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.PropertyEditors.ValueConverters; using Umbraco.Core.Services; using Umbraco.Core.Strings; @@ -13,14 +17,16 @@ namespace Umbraco.Examine { private readonly UrlSegmentProviderCollection _urlSegmentProviders; private readonly IUserService _userService; + private readonly ILogger _logger; public MediaValueSetBuilder(PropertyEditorCollection propertyEditors, UrlSegmentProviderCollection urlSegmentProviders, - IUserService userService) + IUserService userService, ILogger logger) : base(propertyEditors, false) { _urlSegmentProviders = urlSegmentProviders; _userService = userService; + _logger = logger; } /// @@ -29,6 +35,42 @@ namespace Umbraco.Examine foreach (var m in media) { var urlValue = m.GetUrlSegment(_urlSegmentProviders); + + var umbracoFilePath = string.Empty; + var umbracoFile = string.Empty; + + var umbracoFileSource = m.GetValue(Constants.Conventions.Media.File); + + if (umbracoFileSource.DetectIsJson()) + { + ImageCropperValue cropper = null; + try + { + cropper = JsonConvert.DeserializeObject( + m.GetValue(Constants.Conventions.Media.File)); + } + catch (Exception ex) + { + _logger.Error(ex, $"Could not Deserialize ImageCropperValue for item with key {m.Key} "); + } + + if (cropper != null) + { + umbracoFilePath = cropper.Src; + } + } + else + { + umbracoFilePath = umbracoFileSource; + } + + if (!string.IsNullOrEmpty(umbracoFilePath)) + { + // intentional dummy Uri + var uri = new Uri("https://localhost/" + umbracoFilePath); + umbracoFile = uri.Segments.Last(); + } + var values = new Dictionary> { {"icon", m.ContentType.Icon?.Yield() ?? Enumerable.Empty()}, @@ -44,7 +86,8 @@ namespace Umbraco.Examine {"urlName", urlValue?.Yield() ?? Enumerable.Empty()}, {"path", m.Path?.Yield() ?? Enumerable.Empty()}, {"nodeType", m.ContentType.Id.ToString().Yield() }, - {"creatorName", (m.GetCreatorProfile(_userService)?.Name ?? "??").Yield()} + {"creatorName", (m.GetCreatorProfile(_userService)?.Name ?? "??").Yield()}, + {UmbracoExamineIndex.UmbracoFileFieldName, umbracoFile.Yield()} }; foreach (var property in m.Properties) diff --git a/src/Umbraco.Examine/MemberIndexPopulator.cs b/src/Umbraco.Examine/MemberIndexPopulator.cs index e20dab91ca..26a3b0aedd 100644 --- a/src/Umbraco.Examine/MemberIndexPopulator.cs +++ b/src/Umbraco.Examine/MemberIndexPopulator.cs @@ -7,7 +7,7 @@ using Umbraco.Core.Services; namespace Umbraco.Examine { - public class MemberIndexPopulator : IndexPopulator + public class MemberIndexPopulator : IndexPopulator { private readonly IMemberService _memberService; private readonly IValueSetBuilder _valueSetBuilder; diff --git a/src/Umbraco.Examine/Umbraco.Examine.csproj b/src/Umbraco.Examine/Umbraco.Examine.csproj index 95690c17e4..0e0ee62139 100644 --- a/src/Umbraco.Examine/Umbraco.Examine.csproj +++ b/src/Umbraco.Examine/Umbraco.Examine.csproj @@ -10,6 +10,7 @@ Umbraco.Examine ..\ + $(AdditionalFileItemNames);Content true @@ -48,7 +49,7 @@ - + 1.0.0-beta2-19324-01 runtime; build; native; contentfiles; analyzers; buildtransitive @@ -56,6 +57,11 @@ + + 3.3.0 + runtime; build; native; contentfiles; analyzers + all + @@ -64,14 +70,19 @@ + + + + + diff --git a/src/Umbraco.Examine/UmbracoContentIndex.cs b/src/Umbraco.Examine/UmbracoContentIndex.cs index a9e2c72cb6..e266ca789d 100644 --- a/src/Umbraco.Examine/UmbracoContentIndex.cs +++ b/src/Umbraco.Examine/UmbracoContentIndex.cs @@ -17,13 +17,13 @@ namespace Umbraco.Examine /// /// An indexer for Umbraco content and media /// - public class UmbracoContentIndex : UmbracoExamineIndex + public class UmbracoContentIndex : UmbracoExamineIndex, IUmbracoContentIndex { public const string VariesByCultureFieldName = SpecialFieldPrefix + "VariesByCulture"; protected ILocalizationService LanguageService { get; } #region Constructors - + /// /// Create an index at runtime /// @@ -141,6 +141,6 @@ namespace Umbraco.Examine base.PerformDeleteFromIndex(idsAsList, onComplete); } - + } } diff --git a/src/Umbraco.Examine/UmbracoExamineIndex.cs b/src/Umbraco.Examine/UmbracoExamineIndex.cs index 24952050da..e1dd77b994 100644 --- a/src/Umbraco.Examine/UmbracoExamineIndex.cs +++ b/src/Umbraco.Examine/UmbracoExamineIndex.cs @@ -32,6 +32,7 @@ namespace Umbraco.Examine /// public const string IndexPathFieldName = SpecialFieldPrefix + "Path"; public const string NodeKeyFieldName = SpecialFieldPrefix + "Key"; + public const string UmbracoFileFieldName = "umbracoFileSrc"; public const string IconFieldName = SpecialFieldPrefix + "Icon"; public const string PublishedFieldName = SpecialFieldPrefix + "Published"; diff --git a/src/Umbraco.Examine/UmbracoExamineIndexDiagnostics.cs b/src/Umbraco.Examine/UmbracoExamineIndexDiagnostics.cs index fed5b9bae7..4a926deebe 100644 --- a/src/Umbraco.Examine/UmbracoExamineIndexDiagnostics.cs +++ b/src/Umbraco.Examine/UmbracoExamineIndexDiagnostics.cs @@ -7,73 +7,24 @@ using Umbraco.Core.Logging; namespace Umbraco.Examine { - public class UmbracoExamineIndexDiagnostics : IIndexDiagnostics + public class UmbracoExamineIndexDiagnostics : LuceneIndexDiagnostics { private readonly UmbracoExamineIndex _index; - private readonly ILogger _logger; public UmbracoExamineIndexDiagnostics(UmbracoExamineIndex index, ILogger logger) + : base(index, logger) { _index = index; - _logger = logger; } - public int DocumentCount + public override IReadOnlyDictionary Metadata { get { - try - { - return _index.GetIndexDocumentCount(); - } - catch (AlreadyClosedException) - { - _logger.Warn(typeof(UmbracoContentIndex), "Cannot get GetIndexDocumentCount, the writer is already closed"); - return 0; - } - } - } + var d = base.Metadata.ToDictionary(x => x.Key, x => x.Value); - public int FieldCount - { - get - { - try - { - return _index.GetIndexFieldCount(); - } - catch (AlreadyClosedException) - { - _logger.Warn(typeof(UmbracoContentIndex), "Cannot get GetIndexFieldCount, the writer is already closed"); - return 0; - } - } - } - - public Attempt IsHealthy() - { - var isHealthy = _index.IsHealthy(out var indexError); - return isHealthy ? Attempt.Succeed() : Attempt.Fail(indexError.Message); - } - - public virtual IReadOnlyDictionary Metadata - { - get - { - var d = new Dictionary - { - [nameof(UmbracoExamineIndex.CommitCount)] = _index.CommitCount, - [nameof(UmbracoExamineIndex.DefaultAnalyzer)] = _index.DefaultAnalyzer.GetType().Name, - ["LuceneDirectory"] = _index.GetLuceneDirectory().GetType().Name, - [nameof(UmbracoExamineIndex.EnableDefaultEventHandler)] = _index.EnableDefaultEventHandler, - [nameof(UmbracoExamineIndex.LuceneIndexFolder)] = - _index.LuceneIndexFolder == null - ? string.Empty - : _index.LuceneIndexFolder.ToString().ToLowerInvariant().TrimStart(IOHelper.MapPath(SystemDirectories.Root).ToLowerInvariant()).Replace("\\", "/").EnsureStartsWith('/'), - [nameof(UmbracoExamineIndex.PublishedValuesOnly)] = _index.PublishedValuesOnly, - //There's too much info here - //[nameof(UmbracoExamineIndexer.FieldDefinitionCollection)] = _index.FieldDefinitionCollection, - }; + d[nameof(UmbracoExamineIndex.EnableDefaultEventHandler)] = _index.EnableDefaultEventHandler; + d[nameof(UmbracoExamineIndex.PublishedValuesOnly)] = _index.PublishedValuesOnly; if (_index.ValueSetValidator is ValueSetValidator vsv) { diff --git a/src/Umbraco.Examine/UmbracoIndexConfig.cs b/src/Umbraco.Examine/UmbracoIndexConfig.cs new file mode 100644 index 0000000000..7ad9c638d3 --- /dev/null +++ b/src/Umbraco.Examine/UmbracoIndexConfig.cs @@ -0,0 +1,34 @@ +using Examine; +using Umbraco.Core.Services; +using Umbraco.Core.Services.Implement; + +namespace Umbraco.Examine +{ + public class UmbracoIndexConfig : IUmbracoIndexConfig + { + public UmbracoIndexConfig(IPublicAccessService publicAccessService) + { + PublicAccessService = publicAccessService; + } + + protected IPublicAccessService PublicAccessService { get; } + public IContentValueSetValidator GetContentValueSetValidator() + { + return new ContentValueSetValidator(false, true, PublicAccessService); + } + + public IContentValueSetValidator GetPublishedContentValueSetValidator() + { + return new ContentValueSetValidator(true, false, PublicAccessService); + } + + /// + /// Returns the for the member indexer + /// + /// + public IValueSetValidator GetMemberValueSetValidator() + { + return new MemberValueSetValidator(); + } + } +} diff --git a/src/Umbraco.Examine/UmbracoMemberIndex.cs b/src/Umbraco.Examine/UmbracoMemberIndex.cs index fbf8a1cc0f..445707ab0c 100644 --- a/src/Umbraco.Examine/UmbracoMemberIndex.cs +++ b/src/Umbraco.Examine/UmbracoMemberIndex.cs @@ -11,7 +11,7 @@ namespace Umbraco.Examine /// /// Custom indexer for members /// - public class UmbracoMemberIndex : UmbracoExamineIndex + public class UmbracoMemberIndex : UmbracoExamineIndex, IUmbracoMemberIndex { /// /// Constructor to allow for creating an indexer at runtime @@ -32,6 +32,6 @@ namespace Umbraco.Examine base(name, luceneDirectory, fieldDefinitions, analyzer, profilingLogger, validator) { } - + } } diff --git a/src/Umbraco.ModelsBuilder.Embedded/ApiVersion.cs b/src/Umbraco.ModelsBuilder.Embedded/ApiVersion.cs new file mode 100644 index 0000000000..22347edd60 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/ApiVersion.cs @@ -0,0 +1,36 @@ +using System; +using System.Reflection; +using Semver; + +namespace Umbraco.ModelsBuilder.Embedded +{ + /// + /// Manages API version handshake between client and server. + /// + public class ApiVersion + { + /// + /// Initializes a new instance of the class. + /// + /// The currently executing version. + /// + internal ApiVersion(SemVersion executingVersion) + { + Version = executingVersion ?? throw new ArgumentNullException(nameof(executingVersion)); + } + + private static SemVersion CurrentAssemblyVersion + => SemVersion.Parse(Assembly.GetExecutingAssembly().GetCustomAttribute().InformationalVersion); + + /// + /// Gets the currently executing API version. + /// + public static ApiVersion Current { get; } + = new ApiVersion(CurrentAssemblyVersion); + + /// + /// Gets the executing version of the API. + /// + public SemVersion Version { get; } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ContentTypeModelValidator.cs b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ContentTypeModelValidator.cs new file mode 100644 index 0000000000..1fdb64c62a --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ContentTypeModelValidator.cs @@ -0,0 +1,17 @@ +using Umbraco.ModelsBuilder.Embedded.Configuration; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.ModelsBuilder.Embedded.BackOffice +{ + /// + /// Used to validate the aliases for the content type when MB is enabled to ensure that + /// no illegal aliases are used + /// + // ReSharper disable once UnusedMember.Global - This is typed scanned + public class ContentTypeModelValidator : ContentTypeModelValidatorBase + { + public ContentTypeModelValidator(IModelsBuilderConfig config) : base(config) + { + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ContentTypeModelValidatorBase.cs b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ContentTypeModelValidatorBase.cs new file mode 100644 index 0000000000..15ca2cca24 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ContentTypeModelValidatorBase.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Umbraco.Core; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.ModelsBuilder.Embedded.Configuration; +using Umbraco.Web.Editors; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.ModelsBuilder.Embedded.BackOffice +{ + public abstract class ContentTypeModelValidatorBase : EditorValidator + where TModel : ContentTypeSave + where TProperty : PropertyTypeBasic + { + private readonly IModelsBuilderConfig _config; + + public ContentTypeModelValidatorBase(IModelsBuilderConfig config) + { + _config = config; + } + + protected override IEnumerable Validate(TModel model) + { + //don't do anything if we're not enabled + if (!_config.Enable) yield break; + + var properties = model.Groups.SelectMany(x => x.Properties) + .Where(x => x.Inherited == false) + .ToArray(); + + foreach (var prop in properties) + { + var propertyGroup = model.Groups.Single(x => x.Properties.Contains(prop)); + + if (model.Alias.ToLowerInvariant() == prop.Alias.ToLowerInvariant()) + yield return new ValidationResult(string.Format("With Models Builder enabled, you can't have a property with a the alias \"{0}\" when the content type alias is also \"{0}\".", prop.Alias), new[] + { + $"Groups[{model.Groups.IndexOf(propertyGroup)}].Properties[{propertyGroup.Properties.IndexOf(prop)}].Alias" + }); + + //we need to return the field name with an index so it's wired up correctly + var groupIndex = model.Groups.IndexOf(propertyGroup); + var propertyIndex = propertyGroup.Properties.IndexOf(prop); + + var validationResult = ValidateProperty(prop, groupIndex, propertyIndex); + if (validationResult != null) + yield return validationResult; + } + } + + private ValidationResult ValidateProperty(PropertyTypeBasic property, int groupIndex, int propertyIndex) + { + //don't let them match any properties or methods in IPublishedContent + //TODO: There are probably more! + var reservedProperties = typeof(IPublishedContent).GetProperties().Select(x => x.Name).ToArray(); + var reservedMethods = typeof(IPublishedContent).GetMethods().Select(x => x.Name).ToArray(); + + var alias = property.Alias; + + if (reservedProperties.InvariantContains(alias) || reservedMethods.InvariantContains(alias)) + return new ValidationResult( + $"The alias {alias} is a reserved term and cannot be used", new[] + { + $"Groups[{groupIndex}].Properties[{propertyIndex}].Alias" + }); + + return null; + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/BackOffice/DashboardReport.cs b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/DashboardReport.cs new file mode 100644 index 0000000000..25ddc838e8 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/DashboardReport.cs @@ -0,0 +1,61 @@ +using System.Text; +using Umbraco.ModelsBuilder.Embedded.Configuration; + +namespace Umbraco.ModelsBuilder.Embedded.BackOffice +{ + internal class DashboardReport + { + private readonly IModelsBuilderConfig _config; + private readonly OutOfDateModelsStatus _outOfDateModels; + private readonly ModelsGenerationError _mbErrors; + + public DashboardReport(IModelsBuilderConfig config, OutOfDateModelsStatus outOfDateModels, ModelsGenerationError mbErrors) + { + _config = config; + _outOfDateModels = outOfDateModels; + _mbErrors = mbErrors; + } + + public bool CanGenerate() => _config.ModelsMode.SupportsExplicitGeneration(); + + public bool AreModelsOutOfDate() => _outOfDateModels.IsOutOfDate; + + public string LastError() => _mbErrors.GetLastError(); + + public string Text() + { + if (!_config.Enable) + return "Version: " + ApiVersion.Current.Version + "
 
ModelsBuilder is disabled
(the .Enable key is missing, or its value is not 'true')."; + + var sb = new StringBuilder(); + + sb.Append("Version: "); + sb.Append(ApiVersion.Current.Version); + sb.Append("
 
"); + + sb.Append("ModelsBuilder is enabled, with the following configuration:"); + + sb.Append("
    "); + + sb.Append("
  • The models factory is "); + sb.Append(_config.EnableFactory || _config.ModelsMode == ModelsMode.PureLive + ? "enabled" + : "not enabled. Umbraco will not use models"); + sb.Append(".
  • "); + + sb.Append(_config.ModelsMode != ModelsMode.Nothing + ? $"
  • {_config.ModelsMode} models are enabled.
  • " + : "
  • No models mode is specified: models will not be generated.
  • "); + + sb.Append($"
  • Models namespace is {_config.ModelsNamespace}.
  • "); + + sb.Append("
  • Tracking of out-of-date models is "); + sb.Append(_config.FlagOutOfDateModels ? "enabled" : "not enabled"); + sb.Append(".
  • "); + + sb.Append("
"); + + return sb.ToString(); + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/BackOffice/MediaTypeModelValidator.cs b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/MediaTypeModelValidator.cs new file mode 100644 index 0000000000..9dc1ea6c20 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/MediaTypeModelValidator.cs @@ -0,0 +1,17 @@ +using Umbraco.ModelsBuilder.Embedded.Configuration; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.ModelsBuilder.Embedded.BackOffice +{ + /// + /// Used to validate the aliases for the content type when MB is enabled to ensure that + /// no illegal aliases are used + /// + // ReSharper disable once UnusedMember.Global - This is typed scanned + public class MediaTypeModelValidator : ContentTypeModelValidatorBase + { + public MediaTypeModelValidator(IModelsBuilderConfig config) : base(config) + { + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/BackOffice/MemberTypeModelValidator.cs b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/MemberTypeModelValidator.cs new file mode 100644 index 0000000000..8d0a98eeab --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/MemberTypeModelValidator.cs @@ -0,0 +1,17 @@ +using Umbraco.ModelsBuilder.Embedded.Configuration; +using Umbraco.Web.Models.ContentEditing; + +namespace Umbraco.ModelsBuilder.Embedded.BackOffice +{ + /// + /// Used to validate the aliases for the content type when MB is enabled to ensure that + /// no illegal aliases are used + /// + // ReSharper disable once UnusedMember.Global - This is typed scanned + public class MemberTypeModelValidator : ContentTypeModelValidatorBase + { + public MemberTypeModelValidator(IModelsBuilderConfig config) : base(config) + { + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ModelsBuilderDashboardController.cs b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ModelsBuilderDashboardController.cs new file mode 100644 index 0000000000..1d9de265e9 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/BackOffice/ModelsBuilderDashboardController.cs @@ -0,0 +1,146 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Web.Hosting; +using Umbraco.Core.Exceptions; +using Umbraco.ModelsBuilder.Embedded.Building; +using Umbraco.ModelsBuilder.Embedded.Configuration; +using Umbraco.Web.Editors; +using Umbraco.Web.WebApi.Filters; + +namespace Umbraco.ModelsBuilder.Embedded.BackOffice +{ + /// + /// API controller for use in the Umbraco back office with Angular resources + /// + /// + /// We've created a different controller for the backoffice/angular specifically this is to ensure that the + /// correct CSRF security is adhered to for angular and it also ensures that this controller is not subseptipal to + /// global WebApi formatters being changed since this is always forced to only return Angular JSON Specific formats. + /// + [UmbracoApplicationAuthorize(Core.Constants.Applications.Settings)] + public class ModelsBuilderDashboardController : UmbracoAuthorizedJsonController + { + private readonly IModelsBuilderConfig _config; + private readonly ModelsGenerator _modelGenerator; + private readonly OutOfDateModelsStatus _outOfDateModels; + private readonly ModelsGenerationError _mbErrors; + private readonly DashboardReport _dashboardReport; + + public ModelsBuilderDashboardController(IModelsBuilderConfig config, ModelsGenerator modelsGenerator, OutOfDateModelsStatus outOfDateModels, ModelsGenerationError mbErrors) + { + //_umbracoServices = umbracoServices; + _config = config; + _modelGenerator = modelsGenerator; + _outOfDateModels = outOfDateModels; + _mbErrors = mbErrors; + _dashboardReport = new DashboardReport(config, outOfDateModels, mbErrors); + } + + // invoked by the dashboard + // requires that the user is logged into the backoffice and has access to the settings section + // beware! the name of the method appears in modelsbuilder.controller.js + [System.Web.Http.HttpPost] // use the http one, not mvc, with api controllers! + public HttpResponseMessage BuildModels() + { + try + { + var config = _config; + + if (!config.ModelsMode.SupportsExplicitGeneration()) + { + var result2 = new BuildResult { Success = false, Message = "Models generation is not enabled." }; + return Request.CreateResponse(HttpStatusCode.OK, result2, Configuration.Formatters.JsonFormatter); + } + + var bin = HostingEnvironment.MapPath("~/bin"); + if (bin == null) + throw new PanicException("bin is null."); + + // EnableDllModels will recycle the app domain - but this request will end properly + _modelGenerator.GenerateModels(); + _mbErrors.Clear(); + } + catch (Exception e) + { + _mbErrors.Report("Failed to build models.", e); + } + + return Request.CreateResponse(HttpStatusCode.OK, GetDashboardResult(), Configuration.Formatters.JsonFormatter); + } + + // invoked by the back-office + // requires that the user is logged into the backoffice and has access to the settings section + [System.Web.Http.HttpGet] // use the http one, not mvc, with api controllers! + public HttpResponseMessage GetModelsOutOfDateStatus() + { + var status = _outOfDateModels.IsEnabled + ? _outOfDateModels.IsOutOfDate + ? new OutOfDateStatus { Status = OutOfDateType.OutOfDate } + : new OutOfDateStatus { Status = OutOfDateType.Current } + : new OutOfDateStatus { Status = OutOfDateType.Unknown }; + + return Request.CreateResponse(HttpStatusCode.OK, status, Configuration.Formatters.JsonFormatter); + } + + // invoked by the back-office + // requires that the user is logged into the backoffice and has access to the settings section + // beware! the name of the method appears in modelsbuilder.controller.js + [System.Web.Http.HttpGet] // use the http one, not mvc, with api controllers! + public HttpResponseMessage GetDashboard() + { + return Request.CreateResponse(HttpStatusCode.OK, GetDashboardResult(), Configuration.Formatters.JsonFormatter); + } + + private Dashboard GetDashboardResult() + { + return new Dashboard + { + Enable = _config.Enable, + Text = _dashboardReport.Text(), + CanGenerate = _dashboardReport.CanGenerate(), + OutOfDateModels = _dashboardReport.AreModelsOutOfDate(), + LastError = _dashboardReport.LastError(), + }; + } + + [DataContract] + internal class BuildResult + { + [DataMember(Name = "success")] + public bool Success; + [DataMember(Name = "message")] + public string Message; + } + + [DataContract] + internal class Dashboard + { + [DataMember(Name = "enable")] + public bool Enable; + [DataMember(Name = "text")] + public string Text; + [DataMember(Name = "canGenerate")] + public bool CanGenerate; + [DataMember(Name = "outOfDateModels")] + public bool OutOfDateModels; + [DataMember(Name = "lastError")] + public string LastError; + } + + internal enum OutOfDateType + { + OutOfDate, + Current, + Unknown = 100 + } + + [DataContract] + internal class OutOfDateStatus + { + [DataMember(Name = "status")] + public OutOfDateType Status { get; set; } + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/Builder.cs b/src/Umbraco.ModelsBuilder.Embedded/Building/Builder.cs similarity index 54% rename from src/Umbraco.ModelsBuilder/Building/Builder.cs rename to src/Umbraco.ModelsBuilder.Embedded/Building/Builder.cs index acfa402355..ffd56d4312 100644 --- a/src/Umbraco.ModelsBuilder/Building/Builder.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/Building/Builder.cs @@ -1,14 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Umbraco.Core.Configuration; -using Umbraco.ModelsBuilder.Configuration; +using Umbraco.ModelsBuilder.Embedded.Configuration; -namespace Umbraco.ModelsBuilder.Building +namespace Umbraco.ModelsBuilder.Embedded.Building { // NOTE // The idea was to have different types of builder, because I wanted to experiment with @@ -22,10 +17,10 @@ namespace Umbraco.ModelsBuilder.Building ///
internal abstract class Builder { + private readonly IList _typeModels; protected Dictionary ModelsMap { get; } = new Dictionary(); - protected ParseResult ParseResult { get; } // the list of assemblies that will be 'using' by default protected readonly IList TypesUsing = new List @@ -37,8 +32,7 @@ namespace Umbraco.ModelsBuilder.Building "Umbraco.Core.Models", "Umbraco.Core.Models.PublishedContent", "Umbraco.Web", - "Umbraco.ModelsBuilder", - "Umbraco.ModelsBuilder.Umbraco", + "Umbraco.ModelsBuilder.Embedded" }; /// @@ -55,10 +49,10 @@ namespace Umbraco.ModelsBuilder.Building /// /// Gets the list of models to generate. /// - /// The models to generate, ie those that are not ignored. + /// The models to generate public IEnumerable GetModelsToGenerate() { - return _typeModels.Where(x => !x.IsContentIgnored); + return _typeModels; } /// @@ -67,90 +61,39 @@ namespace Umbraco.ModelsBuilder.Building /// Includes those that are ignored. internal IList TypeModels => _typeModels; - /// - /// Initializes a new instance of the class with a list of models to generate - /// and the result of code parsing. - /// - /// The list of models to generate. - /// The result of code parsing. - protected Builder(IList typeModels, ParseResult parseResult) - { - _typeModels = typeModels ?? throw new ArgumentNullException(nameof(typeModels)); - ParseResult = parseResult ?? throw new ArgumentNullException(nameof(parseResult)); - - Prepare(); - } - /// /// Initializes a new instance of the class with a list of models to generate, /// the result of code parsing, and a models namespace. /// /// The list of models to generate. - /// The result of code parsing. /// The models namespace. - protected Builder(IList typeModels, ParseResult parseResult, string modelsNamespace) - : this(typeModels, parseResult) + protected Builder(IModelsBuilderConfig config, IList typeModels) { + _typeModels = typeModels ?? throw new ArgumentNullException(nameof(typeModels)); + + Config = config ?? throw new ArgumentNullException(nameof(config)); + // can be null or empty, we'll manage - ModelsNamespace = modelsNamespace; + ModelsNamespace = Config.ModelsNamespace; + + // but we want it to prepare + Prepare(); } // for unit tests only protected Builder() { } + protected IModelsBuilderConfig Config { get; } + /// /// Prepares generation by processing the result of code parsing. /// - /// - /// Preparation includes figuring out from the existing code which models or properties should - /// be ignored or renamed, etc. -- anything that comes from the attributes in the existing code. - /// private void Prepare() { - var pureLive = UmbracoConfig.For.ModelsBuilder().ModelsMode == ModelsMode.PureLive; + TypeModel.MapModelTypes(_typeModels, ModelsNamespace); - // mark IsContentIgnored models that we discovered should be ignored - // then propagate / ignore children of ignored contents - // ignore content = don't generate a class for it, don't generate children - foreach (var typeModel in _typeModels.Where(x => ParseResult.IsIgnored(x.Alias))) - typeModel.IsContentIgnored = true; - foreach (var typeModel in _typeModels.Where(x => !x.IsContentIgnored && x.EnumerateBaseTypes().Any(xx => xx.IsContentIgnored))) - typeModel.IsContentIgnored = true; - - // handle model renames - foreach (var typeModel in _typeModels.Where(x => ParseResult.IsContentRenamed(x.Alias))) - { - typeModel.ClrName = ParseResult.ContentClrName(typeModel.Alias); - typeModel.IsRenamed = true; - ModelsMap[typeModel.Alias] = typeModel.ClrName; - } - - // handle implement - foreach (var typeModel in _typeModels.Where(x => ParseResult.HasContentImplement(x.Alias))) - { - typeModel.HasImplement = true; - } - - // mark OmitBase models that we discovered already have a base class - foreach (var typeModel in _typeModels.Where(x => ParseResult.HasContentBase(ParseResult.ContentClrName(x.Alias) ?? x.ClrName))) - typeModel.HasBase = true; - - foreach (var typeModel in _typeModels) - { - // mark IsRemoved properties that we discovered should be ignored - // ie is marked as ignored on type, or on any parent type - var tm = typeModel; - foreach (var property in typeModel.Properties - .Where(property => tm.EnumerateBaseTypes(true).Any(x => ParseResult.IsPropertyIgnored(ParseResult.ContentClrName(x.Alias) ?? x.ClrName, property.Alias)))) - { - property.IsIgnored = true; - } - - // handle property renames - foreach (var property in typeModel.Properties) - property.ClrName = ParseResult.PropertyClrName(ParseResult.ContentClrName(typeModel.Alias) ?? typeModel.ClrName, property.Alias) ?? property.ClrName; - } + var pureLive = Config.ModelsMode == ModelsMode.PureLive; // for the first two of these two tests, // always throw, even in purelive: cannot happen unless ppl start fidling with attributes to rename @@ -158,22 +101,22 @@ namespace Umbraco.ModelsBuilder.Building // for the last one, don't throw in purelive, see comment // ensure we have no duplicates type names - foreach (var xx in _typeModels.Where(x => !x.IsContentIgnored).GroupBy(x => x.ClrName).Where(x => x.Count() > 1)) + foreach (var xx in _typeModels.GroupBy(x => x.ClrName).Where(x => x.Count() > 1)) throw new InvalidOperationException($"Type name \"{xx.Key}\" is used" + $" for types with alias {string.Join(", ", xx.Select(x => x.ItemType + ":\"" + x.Alias + "\""))}. Names have to be unique." + " Consider using an attribute to assign different names to conflicting types."); // ensure we have no duplicates property names - foreach (var typeModel in _typeModels.Where(x => !x.IsContentIgnored)) - foreach (var xx in typeModel.Properties.Where(x => !x.IsIgnored).GroupBy(x => x.ClrName).Where(x => x.Count() > 1)) + foreach (var typeModel in _typeModels) + foreach (var xx in typeModel.Properties.GroupBy(x => x.ClrName).Where(x => x.Count() > 1)) throw new InvalidOperationException($"Property name \"{xx.Key}\" in type {typeModel.ItemType}:\"{typeModel.Alias}\"" + $" is used for properties with alias {string.Join(", ", xx.Select(x => "\"" + x.Alias + "\""))}. Names have to be unique." + " Consider using an attribute to assign different names to conflicting properties."); // ensure content & property type don't have identical name (csharp hates it) - foreach (var typeModel in _typeModels.Where(x => !x.IsContentIgnored)) + foreach (var typeModel in _typeModels) { - foreach (var xx in typeModel.Properties.Where(x => !x.IsIgnored && x.ClrName == typeModel.ClrName)) + foreach (var xx in typeModel.Properties.Where(x => x.ClrName == typeModel.ClrName)) { if (!pureLive) throw new InvalidOperationException($"The model class for content type with alias \"{typeModel.Alias}\" is named \"{xx.ClrName}\"." @@ -204,7 +147,7 @@ namespace Umbraco.ModelsBuilder.Building // collect all the (non-removed) types implemented at parent level // ie the parent content types and the mixins content types, recursively var parentImplems = new List(); - if (typeModel.BaseType != null && !typeModel.BaseType.IsContentIgnored) + if (typeModel.BaseType != null) TypeModel.CollectImplems(parentImplems, typeModel.BaseType); // interfaces we must declare we implement (initially empty) @@ -212,7 +155,6 @@ namespace Umbraco.ModelsBuilder.Building // and except those that are already declared at the parent level // in other words, DeclaringInterfaces is "local mixins" var declaring = typeModel.MixinTypes - .Where(x => !x.IsContentIgnored) .Except(parentImplems); typeModel.DeclaringInterfaces.AddRange(declaring); @@ -227,43 +169,16 @@ namespace Umbraco.ModelsBuilder.Building typeModel.ImplementingInterfaces.AddRange(mixinImplems.Except(parentImplems)); } - // register using types - foreach (var usingNamespace in ParseResult.UsingNamespaces) + // ensure elements don't inherit from non-elements + foreach (var typeModel in _typeModels.Where(x => x.IsElement)) { - if (!TypesUsing.Contains(usingNamespace)) - TypesUsing.Add(usingNamespace); + if (typeModel.BaseType != null && !typeModel.BaseType.IsElement) + throw new InvalidOperationException($"Cannot generate model for type '{typeModel.Alias}' because it is an element type, but its parent type '{typeModel.BaseType.Alias}' is not."); + + var errs = typeModel.MixinTypes.Where(x => !x.IsElement).ToList(); + if (errs.Count > 0) + throw new InvalidOperationException($"Cannot generate model for type '{typeModel.Alias}' because it is an element type, but it is composed of {string.Join(", ", errs.Select(x => "'" + x.Alias + "'"))} which {(errs.Count == 1 ? "is" : "are")} not."); } - - // discover static mixin methods - foreach (var typeModel in _typeModels) - typeModel.StaticMixinMethods.AddRange(ParseResult.StaticMixinMethods(typeModel.ClrName)); - - // handle ctor - foreach (var typeModel in _typeModels.Where(x => ParseResult.HasCtor(x.ClrName))) - typeModel.HasCtor = true; - } - - private SemanticModel _ambiguousSymbolsModel; - private int _ambiguousSymbolsPos; - - // internal for tests - internal void PrepareAmbiguousSymbols() - { - var codeBuilder = new StringBuilder(); - foreach (var t in TypesUsing) - codeBuilder.AppendFormat("using {0};\n", t); - - codeBuilder.AppendFormat("namespace {0}\n{{ }}\n", GetModelsNamespace()); - - var compiler = new Compiler(); - SyntaxTree[] trees; - var compilation = compiler.GetCompilation("MyCompilation", new Dictionary { { "code", codeBuilder.ToString() } }, out trees); - var tree = trees[0]; - _ambiguousSymbolsModel = compilation.GetSemanticModel(tree); - - var namespaceSyntax = tree.GetRoot().DescendantNodes().OfType().First(); - //var namespaceSymbol = model.GetDeclaredSymbol(namespaceSyntax); - _ambiguousSymbolsPos = namespaceSyntax.OpenBraceToken.SpanStart; } // looking for a simple symbol eg 'Umbraco' or 'String' @@ -273,20 +188,12 @@ namespace Umbraco.ModelsBuilder.Building // - 1 symbol is found BUT not matching (implicitely ambiguous) protected bool IsAmbiguousSymbol(string symbol, string match) { - if (_ambiguousSymbolsModel == null) - PrepareAmbiguousSymbols(); - if (_ambiguousSymbolsModel == null) - throw new Exception("Could not prepare ambiguous symbols."); - var symbols = _ambiguousSymbolsModel.LookupNamespacesAndTypes(_ambiguousSymbolsPos, null, symbol); + // cannot figure out is a symbol is ambiguous without Roslyn + // so... let's say everything is ambiguous - code won't be + // pretty but it'll work - if (symbols.Length > 1) return true; - if (symbols.Length == 0) return false; // what else? - - // only 1 - ensure it matches - var found = symbols[0].ToDisplayString(); - var pos = found.IndexOf('<'); // generic? - if (pos > 0) found = found.Substring(0, pos); // strip - return found != match; // and compare + // Essentially this means that a `global::` syntax will be output for the generated models + return true; } internal string ModelsNamespaceForTests; @@ -296,25 +203,18 @@ namespace Umbraco.ModelsBuilder.Building if (ModelsNamespaceForTests != null) return ModelsNamespaceForTests; - // code attribute overrides everything - if (ParseResult.HasModelsNamespace) - return ParseResult.ModelsNamespace; - // if builder was initialized with a namespace, use that one if (!string.IsNullOrWhiteSpace(ModelsNamespace)) return ModelsNamespace; - // default - // fixme - should NOT reference config here, should make ModelsNamespace mandatory - return UmbracoConfig.For.ModelsBuilder().ModelsNamespace; + // use configured else fallback to default + return string.IsNullOrWhiteSpace(Config.ModelsNamespace) + ? ModelsBuilderConfig.DefaultModelsNamespace + : Config.ModelsNamespace; } protected string GetModelsBaseClassName(TypeModel type) { - // code attribute overrides everything - if (ParseResult.HasModelsBaseClassName) - return ParseResult.ModelsBaseClassName; - // default return type.IsElement ? "PublishedElementModel" : "PublishedContentModel"; } diff --git a/src/Umbraco.ModelsBuilder.Embedded/Building/ModelsGenerator.cs b/src/Umbraco.ModelsBuilder.Embedded/Building/ModelsGenerator.cs new file mode 100644 index 0000000000..8a3bc5a5b5 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/Building/ModelsGenerator.cs @@ -0,0 +1,54 @@ +using System.IO; +using System.Text; +using Umbraco.ModelsBuilder.Embedded.Configuration; + +namespace Umbraco.ModelsBuilder.Embedded.Building +{ + public class ModelsGenerator + { + private readonly UmbracoServices _umbracoService; + private readonly IModelsBuilderConfig _config; + private readonly OutOfDateModelsStatus _outOfDateModels; + + public ModelsGenerator(UmbracoServices umbracoService, IModelsBuilderConfig config, OutOfDateModelsStatus outOfDateModels) + { + _umbracoService = umbracoService; + _config = config; + _outOfDateModels = outOfDateModels; + } + + internal void GenerateModels() + { + if (!Directory.Exists(_config.ModelsDirectory)) + Directory.CreateDirectory(_config.ModelsDirectory); + + foreach (var file in Directory.GetFiles(_config.ModelsDirectory, "*.generated.cs")) + File.Delete(file); + + var typeModels = _umbracoService.GetAllTypes(); + + var builder = new TextBuilder(_config, typeModels); + + foreach (var typeModel in builder.GetModelsToGenerate()) + { + var sb = new StringBuilder(); + builder.Generate(sb, typeModel); + var filename = Path.Combine(_config.ModelsDirectory, typeModel.ClrName + ".generated.cs"); + File.WriteAllText(filename, sb.ToString()); + } + + // the idea was to calculate the current hash and to add it as an extra file to the compilation, + // in order to be able to detect whether a DLL is consistent with an environment - however the + // environment *might not* contain the local partial files, and thus it could be impossible to + // calculate the hash. So... maybe that's not a good idea after all? + /* + var currentHash = HashHelper.Hash(ourFiles, typeModels); + ourFiles["models.hash.cs"] = $@"using Umbraco.ModelsBuilder; +[assembly:ModelsBuilderAssembly(SourceHash = ""{currentHash}"")] +"; + */ + + _outOfDateModels.Clear(); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/Building/PropertyModel.cs b/src/Umbraco.ModelsBuilder.Embedded/Building/PropertyModel.cs similarity index 89% rename from src/Umbraco.ModelsBuilder/Building/PropertyModel.cs rename to src/Umbraco.ModelsBuilder.Embedded/Building/PropertyModel.cs index 1595b3f888..af5445b175 100644 --- a/src/Umbraco.ModelsBuilder/Building/PropertyModel.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/Building/PropertyModel.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace Umbraco.ModelsBuilder.Building +namespace Umbraco.ModelsBuilder.Embedded.Building { /// /// Represents a model property. @@ -41,11 +41,6 @@ namespace Umbraco.ModelsBuilder.Building /// public string ClrTypeName; - /// - /// Gets a value indicating whether this property should be excluded from generation. - /// - public bool IsIgnored; - /// /// Gets the generation errors for the property. /// diff --git a/src/Umbraco.ModelsBuilder/Building/TextBuilder.cs b/src/Umbraco.ModelsBuilder.Embedded/Building/TextBuilder.cs similarity index 83% rename from src/Umbraco.ModelsBuilder/Building/TextBuilder.cs rename to src/Umbraco.ModelsBuilder.Embedded/Building/TextBuilder.cs index 85ccd541b7..d1190a0374 100644 --- a/src/Umbraco.ModelsBuilder/Building/TextBuilder.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/Building/TextBuilder.cs @@ -3,11 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; -using Umbraco.Core.Configuration; -using Umbraco.Core.Models.PublishedContent; -using Umbraco.ModelsBuilder.Configuration; +using Umbraco.ModelsBuilder.Embedded.Configuration; -namespace Umbraco.ModelsBuilder.Building +namespace Umbraco.ModelsBuilder.Embedded.Building { /// /// Implements a builder that works by writing text. @@ -19,20 +17,8 @@ namespace Umbraco.ModelsBuilder.Building /// and the result of code parsing. /// /// The list of models to generate. - /// The result of code parsing. - public TextBuilder(IList typeModels, ParseResult parseResult) - : base(typeModels, parseResult) - { } - - /// - /// Initializes a new instance of the class with a list of models to generate, - /// the result of code parsing, and a models namespace. - /// - /// The list of models to generate. - /// The result of code parsing. - /// The models namespace. - public TextBuilder(IList typeModels, ParseResult parseResult, string modelsNamespace) - : base(typeModels, parseResult, modelsNamespace) + public TextBuilder(IModelsBuilderConfig config, IList typeModels) + : base(config, typeModels) { } // internal for unit tests only @@ -97,6 +83,20 @@ namespace Umbraco.ModelsBuilder.Building TextHeaderWriter.WriteHeader(sb); } + // writes an attribute that identifies code generated by a tool + // (helps reduce warnings, tools such as FxCop use it) + // see https://github.com/zpqrtbnk/Zbu.ModelsBuilder/issues/107 + // see https://docs.microsoft.com/en-us/dotnet/api/system.codedom.compiler.generatedcodeattribute + // see https://blogs.msdn.microsoft.com/codeanalysis/2007/04/27/correct-usage-of-the-compilergeneratedattribute-and-the-generatedcodeattribute/ + // + // note that the blog post above clearly states that "Nor should it be applied at the type level if the type being generated is a partial class." + // and since our models are partial classes, we have to apply the attribute against the individual members, not the class itself. + // + private static void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) + { + sb.AppendFormat("{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"Umbraco.ModelsBuilder\", \"{1}\")]\n", tabs, ApiVersion.Current.Version); + } + private void WriteContentType(StringBuilder sb, TypeModel type) { string sep; @@ -104,11 +104,11 @@ namespace Umbraco.ModelsBuilder.Building if (type.IsMixin) { // write the interface declaration - sb.AppendFormat("\t// Mixin content Type {0} with alias \"{1}\"\n", type.Id, type.Alias); + sb.AppendFormat("\t// Mixin Content Type with alias \"{0}\"\n", type.Alias); if (!string.IsNullOrWhiteSpace(type.Name)) sb.AppendFormat("\t/// {0}\n", XmlCommentString(type.Name)); sb.AppendFormat("\tpublic partial interface I{0}", type.ClrName); - var implements = type.BaseType == null || type.BaseType.IsContentIgnored + var implements = type.BaseType == null ? (type.HasBase ? null : (type.IsElement ? "PublishedElement" : "PublishedContent")) : type.BaseType.ClrName; if (implements != null) @@ -126,7 +126,7 @@ namespace Umbraco.ModelsBuilder.Building // write the properties - only the local (non-ignored) ones, we're an interface var more = false; - foreach (var prop in type.Properties.Where(x => !x.IsIgnored).OrderBy(x => x.ClrName)) + foreach (var prop in type.Properties.OrderBy(x => x.ClrName)) { if (more) sb.Append("\n"); more = true; @@ -137,8 +137,6 @@ namespace Umbraco.ModelsBuilder.Building } // write the class declaration - if (type.IsRenamed) - sb.AppendFormat("\t// Content Type {0} with alias \"{1}\"\n", type.Id, type.Alias); if (!string.IsNullOrWhiteSpace(type.Name)) sb.AppendFormat("\t/// {0}\n", XmlCommentString(type.Name)); // cannot do it now. see note in ImplementContentTypeAttribute @@ -148,7 +146,7 @@ namespace Umbraco.ModelsBuilder.Building sb.AppendFormat("\tpublic partial class {0}", type.ClrName); var inherits = type.HasBase ? null // has its own base already - : (type.BaseType == null || type.BaseType.IsContentIgnored + : (type.BaseType == null ? GetModelsBaseClassName(type) : type.BaseType.ClrName); if (inherits != null) @@ -178,22 +176,25 @@ namespace Umbraco.ModelsBuilder.Building // as 'new' since parent has its own - or maybe not - disable warning sb.Append("\t\t// helpers\n"); sb.Append("#pragma warning disable 0109 // new is redundant\n"); + WriteGeneratedCodeAttribute(sb, "\t\t"); sb.AppendFormat("\t\tpublic new const string ModelTypeAlias = \"{0}\";\n", type.Alias); var itemType = type.IsElement ? TypeModel.ItemTypes.Content : type.ItemType; // fixme + WriteGeneratedCodeAttribute(sb, "\t\t"); sb.AppendFormat("\t\tpublic new const PublishedItemType ModelItemType = PublishedItemType.{0};\n", itemType); - sb.Append("\t\tpublic new static PublishedContentType GetModelContentType()\n"); + WriteGeneratedCodeAttribute(sb, "\t\t"); + sb.Append("\t\tpublic new static IPublishedContentType GetModelContentType()\n"); sb.Append("\t\t\t=> PublishedModelUtility.GetModelContentType(ModelItemType, ModelTypeAlias);\n"); - sb.AppendFormat("\t\tpublic static PublishedPropertyType GetModelPropertyType(Expression> selector)\n", + WriteGeneratedCodeAttribute(sb, "\t\t"); + sb.AppendFormat("\t\tpublic static IPublishedPropertyType GetModelPropertyType(Expression> selector)\n", type.ClrName); sb.Append("\t\t\t=> PublishedModelUtility.GetModelPropertyType(GetModelContentType(), selector);\n"); sb.Append("#pragma warning restore 0109\n\n"); // write the ctor - if (!type.HasCtor) - sb.AppendFormat("\t\t// ctor\n\t\tpublic {0}(IPublished{1} content)\n\t\t\t: base(content)\n\t\t{{ }}\n\n", - type.ClrName, type.IsElement ? "Element" : "Content"); + sb.AppendFormat("\t\t// ctor\n\t\tpublic {0}(IPublished{1} content)\n\t\t\t: base(content)\n\t\t{{ }}\n\n", + type.ClrName, type.IsElement ? "Element" : "Content"); // write the properties sb.Append("\t\t// properties\n"); @@ -205,10 +206,10 @@ namespace Umbraco.ModelsBuilder.Building private void WriteContentTypeProperties(StringBuilder sb, TypeModel type) { - var staticMixinGetters = UmbracoConfig.For.ModelsBuilder().StaticMixinGetters; + var staticMixinGetters = true; // write the properties - foreach (var prop in type.Properties.Where(x => !x.IsIgnored).OrderBy(x => x.ClrName)) + foreach (var prop in type.Properties.OrderBy(x => x.ClrName)) WriteProperty(sb, type, prop, staticMixinGetters && type.IsMixin ? type.ClrName : null); // no need to write the parent properties since we inherit from the parent @@ -217,7 +218,7 @@ namespace Umbraco.ModelsBuilder.Building // write the mixins properties foreach (var mixinType in type.ImplementingInterfaces.OrderBy(x => x.ClrName)) - foreach (var prop in mixinType.Properties.Where(x => !x.IsIgnored).OrderBy(x => x.ClrName)) + foreach (var prop in mixinType.Properties.OrderBy(x => x.ClrName)) if (staticMixinGetters) WriteMixinProperty(sb, prop, mixinType.ClrName); else @@ -242,6 +243,7 @@ namespace Umbraco.ModelsBuilder.Building sb.Append("\t\t///\n"); } + WriteGeneratedCodeAttribute(sb, "\t\t"); sb.AppendFormat("\t\t[ImplementPropertyType(\"{0}\")]\n", property.Alias); sb.Append("\t\tpublic "); @@ -256,7 +258,7 @@ namespace Umbraco.ModelsBuilder.Building private static string MixinStaticGetterName(string clrName) { - return string.Format(UmbracoConfig.For.ModelsBuilder().StaticMixinGetterPattern, clrName); + return string.Format("Get{0}", clrName); } private void WriteProperty(StringBuilder sb, TypeModel type, PropertyModel property, string mixinClrName = null) @@ -300,6 +302,7 @@ namespace Umbraco.ModelsBuilder.Building sb.Append("\t\t///\n"); } + WriteGeneratedCodeAttribute(sb, "\t\t"); sb.AppendFormat("\t\t[ImplementPropertyType(\"{0}\")]\n", property.Alias); if (mixinStatic) @@ -336,13 +339,14 @@ namespace Umbraco.ModelsBuilder.Building var mixinStaticGetterName = MixinStaticGetterName(property.ClrName); - if (type.StaticMixinMethods.Contains(mixinStaticGetterName)) return; + //if (type.StaticMixinMethods.Contains(mixinStaticGetterName)) return; sb.Append("\n"); if (!string.IsNullOrWhiteSpace(property.Name)) sb.AppendFormat("\t\t/// Static getter for {0}\n", XmlCommentString(property.Name)); + WriteGeneratedCodeAttribute(sb, "\t\t"); sb.Append("\t\tpublic static "); WriteClrType(sb, property.ClrTypeName); sb.AppendFormat(" {0}(I{1} that) => that.Value", @@ -397,6 +401,7 @@ namespace Umbraco.ModelsBuilder.Building if (!string.IsNullOrWhiteSpace(property.Name)) sb.AppendFormat("\t\t/// {0}\n", XmlCommentString(property.Name)); + WriteGeneratedCodeAttribute(sb, "\t\t"); sb.Append("\t\t"); WriteClrType(sb, property.ClrTypeName); sb.AppendFormat(" {0} {{ get; }}\n", @@ -461,7 +466,7 @@ namespace Umbraco.ModelsBuilder.Building s = Regex.Replace(s, @"\{(.*)\}\[\*\]", m => ModelsMap[m.Groups[1].Value + "[]"]); // takes care eg of "System.Int32" vs. "int" - if (TypesMap.TryGetValue(s.ToLowerInvariant(), out string typeName)) + if (TypesMap.TryGetValue(s, out string typeName)) { sb.Append(typeName); return; @@ -481,6 +486,11 @@ namespace Umbraco.ModelsBuilder.Building typeName = typeName.Substring(p + 1); typeUsing = x; } + else if (x == ModelsNamespace) // that one is used by default + { + typeName = typeName.Substring(p + 1); + typeUsing = ModelsNamespace; + } } // nested types *after* using @@ -531,24 +541,24 @@ namespace Umbraco.ModelsBuilder.Building return s.Replace('<', '{').Replace('>', '}').Replace('\r', ' ').Replace('\n', ' '); } - private static readonly IDictionary TypesMap = new Dictionary + private static readonly IDictionary TypesMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { - { "system.int16", "short" }, - { "system.int32", "int" }, - { "system.int64", "long" }, - { "system.string", "string" }, - { "system.object", "object" }, - { "system.boolean", "bool" }, - { "system.void", "void" }, - { "system.char", "char" }, - { "system.byte", "byte" }, - { "system.uint16", "ushort" }, - { "system.uint32", "uint" }, - { "system.uint64", "ulong" }, - { "system.sbyte", "sbyte" }, - { "system.single", "float" }, - { "system.double", "double" }, - { "system.decimal", "decimal" } + { "System.Int16", "short" }, + { "System.Int32", "int" }, + { "System.Int64", "long" }, + { "System.String", "string" }, + { "System.Object", "object" }, + { "System.Boolean", "bool" }, + { "System.Void", "void" }, + { "System.Char", "char" }, + { "System.Byte", "byte" }, + { "System.UInt16", "ushort" }, + { "System.UInt32", "uint" }, + { "System.UInt64", "ulong" }, + { "System.SByte", "sbyte" }, + { "System.Single", "float" }, + { "System.Double", "double" }, + { "System.Decimal", "decimal" } }; } } diff --git a/src/Umbraco.ModelsBuilder/Building/TextHeaderWriter.cs b/src/Umbraco.ModelsBuilder.Embedded/Building/TextHeaderWriter.cs similarity index 89% rename from src/Umbraco.ModelsBuilder/Building/TextHeaderWriter.cs rename to src/Umbraco.ModelsBuilder.Embedded/Building/TextHeaderWriter.cs index d165f03907..a93df97806 100644 --- a/src/Umbraco.ModelsBuilder/Building/TextHeaderWriter.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/Building/TextHeaderWriter.cs @@ -1,9 +1,8 @@ using System.Text; -using Umbraco.ModelsBuilder.Api; -namespace Umbraco.ModelsBuilder.Building +namespace Umbraco.ModelsBuilder.Embedded.Building { - public static class TextHeaderWriter + internal static class TextHeaderWriter { /// /// Outputs an "auto-generated" header to a string builder. diff --git a/src/Umbraco.ModelsBuilder/Building/TypeModel.cs b/src/Umbraco.ModelsBuilder.Embedded/Building/TypeModel.cs similarity index 85% rename from src/Umbraco.ModelsBuilder/Building/TypeModel.cs rename to src/Umbraco.ModelsBuilder.Embedded/Building/TypeModel.cs index 5ada8e881c..95356cf3ff 100644 --- a/src/Umbraco.ModelsBuilder/Building/TypeModel.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/Building/TypeModel.cs @@ -1,8 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Umbraco.Core.Models.PublishedContent; -namespace Umbraco.ModelsBuilder.Building +namespace Umbraco.ModelsBuilder.Embedded.Building { /// /// Represents a model. @@ -76,10 +77,10 @@ namespace Umbraco.ModelsBuilder.Building /// public readonly List ImplementingInterfaces = new List(); - /// - /// Gets the list of existing static mixin method candidates. - /// - public readonly List StaticMixinMethods = new List(); + ///// + ///// Gets the list of existing static mixin method candidates. + ///// + //public readonly List StaticMixinMethods = new List(); //TODO: Do we need this? it isn't used /// /// Gets a value indicating whether this model has a base class. @@ -88,16 +89,6 @@ namespace Umbraco.ModelsBuilder.Building /// or because the existing user's code declares a base class for this model. public bool HasBase; - /// - /// Gets a value indicating whether this model has been renamed. - /// - public bool IsRenamed; - - /// - /// Gets a value indicating whether this model has [ImplementContentType] already. - /// - public bool HasImplement; - /// /// Gets a value indicating whether this model is used as a mixin by another model. /// @@ -108,16 +99,6 @@ namespace Umbraco.ModelsBuilder.Building /// public bool IsParent; - /// - /// Gets a value indicating whether this model should be excluded from generation. - /// - public bool IsContentIgnored; - - /// - /// Gets a value indicating whether the ctor is already defined in a partial. - /// - public bool HasCtor; - /// /// Gets a value indicating whether the type is an element. /// @@ -181,11 +162,11 @@ namespace Umbraco.ModelsBuilder.Building /// Includes the specified type. internal static void CollectImplems(ICollection types, TypeModel type) { - if (!type.IsContentIgnored && types.Contains(type) == false) + if (types.Contains(type) == false) types.Add(type); - if (type.BaseType != null && !type.BaseType.IsContentIgnored) + if (type.BaseType != null) CollectImplems(types, type.BaseType); - foreach (var mixin in type.MixinTypes.Where(x => !x.IsContentIgnored)) + foreach (var mixin in type.MixinTypes) CollectImplems(types, mixin); } @@ -204,5 +185,21 @@ namespace Umbraco.ModelsBuilder.Building typeModel = typeModel.BaseType; } } + + /// + /// Maps ModelType. + /// + public static void MapModelTypes(IList typeModels, string ns) + { + var hasNs = !string.IsNullOrWhiteSpace(ns); + var map = typeModels.ToDictionary(x => x.Alias, x => hasNs ? (ns + "." + x.ClrName) : x.ClrName); + foreach (var typeModel in typeModels) + { + foreach (var propertyModel in typeModel.Properties) + { + propertyModel.ClrTypeName = ModelType.MapToName(propertyModel.ModelClrType, map); + } + } + } } } diff --git a/src/Umbraco.ModelsBuilder/Umbraco/HashHelper.cs b/src/Umbraco.ModelsBuilder.Embedded/Building/TypeModelHasher.cs similarity index 81% rename from src/Umbraco.ModelsBuilder/Umbraco/HashHelper.cs rename to src/Umbraco.ModelsBuilder.Embedded/Building/TypeModelHasher.cs index c530cbbd6b..2f14bec875 100644 --- a/src/Umbraco.ModelsBuilder/Umbraco/HashHelper.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/Building/TypeModelHasher.cs @@ -1,18 +1,14 @@ using System.Collections.Generic; using System.Linq; -using Umbraco.ModelsBuilder.Building; -namespace Umbraco.ModelsBuilder.Umbraco +namespace Umbraco.ModelsBuilder.Embedded.Building { - class HashHelper + internal class TypeModelHasher { - public static string Hash(IDictionary ourFiles, IEnumerable typeModels) + public static string Hash(IEnumerable typeModels) { var hash = new HashCombiner(); - foreach (var kvp in ourFiles) - hash.Add(kvp.Key + "::" + kvp.Value); - // see Umbraco.ModelsBuilder.Umbraco.Application for what's important to hash // ie what comes from Umbraco (not computed by ModelsBuilder) and makes a difference @@ -39,6 +35,9 @@ namespace Umbraco.ModelsBuilder.Umbraco } } + // Include the MB version in the hash so that if the MB version changes, models are rebuilt + hash.Add(ApiVersion.Current.Version.ToString()); + return hash.GetCombinedHashCode(); } } diff --git a/src/Umbraco.ModelsBuilder.Embedded/Compose/DisabledModelsBuilderComponent.cs b/src/Umbraco.ModelsBuilder.Embedded/Compose/DisabledModelsBuilderComponent.cs new file mode 100644 index 0000000000..c599785711 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/Compose/DisabledModelsBuilderComponent.cs @@ -0,0 +1,30 @@ +using Umbraco.Core; +using Umbraco.Core.Composing; +using Umbraco.ModelsBuilder.Embedded.BackOffice; +using Umbraco.Web.Features; + +namespace Umbraco.ModelsBuilder.Embedded.Compose +{ + /// + /// Special component used for when MB is disabled with the legacy MB is detected + /// + internal class DisabledModelsBuilderComponent : IComponent + { + private readonly UmbracoFeatures _features; + + public DisabledModelsBuilderComponent(UmbracoFeatures features) + { + _features = features; + } + + public void Initialize() + { + //disable the embedded dashboard controller + _features.Disabled.Controllers.Add(); + } + + public void Terminate() + { + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderComponent.cs b/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderComponent.cs new file mode 100644 index 0000000000..0e41c9ac62 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderComponent.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Web; +using System.Web.Mvc; +using System.Web.Routing; +using Umbraco.Core.Composing; +using Umbraco.Core.IO; +using Umbraco.Core.Services; +using Umbraco.Core.Services.Implement; +using Umbraco.ModelsBuilder.Embedded.BackOffice; +using Umbraco.ModelsBuilder.Embedded.Configuration; +using Umbraco.Web; +using Umbraco.Web.JavaScript; +using Umbraco.Web.Mvc; + +namespace Umbraco.ModelsBuilder.Embedded.Compose +{ + + internal class ModelsBuilderComponent : IComponent + { + + private readonly IModelsBuilderConfig _config; + private readonly LiveModelsProvider _liveModelsProvider; + private readonly OutOfDateModelsStatus _outOfDateModels; + + public ModelsBuilderComponent(IModelsBuilderConfig config, LiveModelsProvider liveModelsProvider, OutOfDateModelsStatus outOfDateModels) + { + _config = config; + _liveModelsProvider = liveModelsProvider; + _outOfDateModels = outOfDateModels; + } + + public void Initialize() + { + // always setup the dashboard + // note: UmbracoApiController instances are automatically registered + InstallServerVars(); + + ContentModelBinder.ModelBindingException += ContentModelBinder_ModelBindingException; + + if (_config.Enable) + FileService.SavingTemplate += FileService_SavingTemplate; + + if (_config.ModelsMode.IsLiveNotPure()) + _liveModelsProvider.Install(); + + if (_config.FlagOutOfDateModels) + _outOfDateModels.Install(); + } + + public void Terminate() + { } + + private void InstallServerVars() + { + // register our url - for the backoffice api + ServerVariablesParser.Parsing += (sender, serverVars) => + { + if (!serverVars.ContainsKey("umbracoUrls")) + throw new ArgumentException("Missing umbracoUrls."); + var umbracoUrlsObject = serverVars["umbracoUrls"]; + if (umbracoUrlsObject == null) + throw new ArgumentException("Null umbracoUrls"); + if (!(umbracoUrlsObject is Dictionary umbracoUrls)) + throw new ArgumentException("Invalid umbracoUrls"); + + if (!serverVars.ContainsKey("umbracoPlugins")) + throw new ArgumentException("Missing umbracoPlugins."); + if (!(serverVars["umbracoPlugins"] is Dictionary umbracoPlugins)) + throw new ArgumentException("Invalid umbracoPlugins"); + + if (HttpContext.Current == null) throw new InvalidOperationException("HttpContext is null"); + var urlHelper = new UrlHelper(new RequestContext(new HttpContextWrapper(HttpContext.Current), new RouteData())); + + umbracoUrls["modelsBuilderBaseUrl"] = urlHelper.GetUmbracoApiServiceBaseUrl(controller => controller.BuildModels()); + umbracoPlugins["modelsBuilder"] = GetModelsBuilderSettings(); + }; + } + + private Dictionary GetModelsBuilderSettings() + { + var settings = new Dictionary + { + {"enabled", _config.Enable} + }; + + return settings; + } + + /// + /// Used to check if a template is being created based on a document type, in this case we need to + /// ensure the template markup is correct based on the model name of the document type + /// + /// + /// + private void FileService_SavingTemplate(IFileService sender, Core.Events.SaveEventArgs e) + { + // don't do anything if the factory is not enabled + // because, no factory = no models (even if generation is enabled) + if (!_config.EnableFactory) return; + + // don't do anything if this special key is not found + if (!e.AdditionalData.ContainsKey("CreateTemplateForContentType")) return; + + // ensure we have the content type alias + if (!e.AdditionalData.ContainsKey("ContentTypeAlias")) + throw new InvalidOperationException("The additionalData key: ContentTypeAlias was not found"); + + foreach (var template in e.SavedEntities) + // if it is in fact a new entity (not been saved yet) and the "CreateTemplateForContentType" key + // is found, then it means a new template is being created based on the creation of a document type + if (!template.HasIdentity && string.IsNullOrWhiteSpace(template.Content)) + { + // ensure is safe and always pascal cased, per razor standard + // + this is how we get the default model name in Umbraco.ModelsBuilder.Umbraco.Application + var alias = e.AdditionalData["ContentTypeAlias"].ToString(); + var name = template.Name; // will be the name of the content type since we are creating + var className = UmbracoServices.GetClrName(name, alias); + + var modelNamespace = _config.ModelsNamespace; + + // we do not support configuring this at the moment, so just let Umbraco use its default value + //var modelNamespaceAlias = ...; + + var markup = ViewHelper.GetDefaultFileContent( + modelClassName: className, + modelNamespace: modelNamespace/*, + modelNamespaceAlias: modelNamespaceAlias*/); + + //set the template content to the new markup + template.Content = markup; + } + } + + private void ContentModelBinder_ModelBindingException(object sender, ContentModelBinder.ModelBindingArgs args) + { + var sourceAttr = args.SourceType.Assembly.GetCustomAttribute(); + var modelAttr = args.ModelType.Assembly.GetCustomAttribute(); + + // if source or model is not a ModelsBuider type... + if (sourceAttr == null || modelAttr == null) + { + // if neither are ModelsBuilder types, give up entirely + if (sourceAttr == null && modelAttr == null) + return; + + // else report, but better not restart (loops?) + args.Message.Append(" The "); + args.Message.Append(sourceAttr == null ? "view model" : "source"); + args.Message.Append(" is a ModelsBuilder type, but the "); + args.Message.Append(sourceAttr != null ? "view model" : "source"); + args.Message.Append(" is not. The application is in an unstable state and should be restarted."); + return; + } + + // both are ModelsBuilder types + var pureSource = sourceAttr.PureLive; + var pureModel = modelAttr.PureLive; + + if (sourceAttr.PureLive || modelAttr.PureLive) + if (pureSource == false || pureModel == false) + { + // only one is pure - report, but better not restart (loops?) + args.Message.Append(pureSource + ? " The content model is PureLive, but the view model is not." + : " The view model is PureLive, but the content model is not."); + args.Message.Append(" The application is in an unstable state and should be restarted."); + } + else + { + // both are pure - report, and if different versions, restart + // if same version... makes no sense... and better not restart (loops?) + var sourceVersion = args.SourceType.Assembly.GetName().Version; + var modelVersion = args.ModelType.Assembly.GetName().Version; + args.Message.Append(" Both view and content models are PureLive, with "); + args.Message.Append(sourceVersion == modelVersion + ? "same version. The application is in an unstable state and should be restarted." + : "different versions. The application is in an unstable state and is going to be restarted."); + args.Restart = sourceVersion != modelVersion; + } + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderComposer.cs b/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderComposer.cs new file mode 100644 index 0000000000..45c4de5d2a --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderComposer.cs @@ -0,0 +1,103 @@ +using System.Linq; +using System.Reflection; +using Umbraco.Core; +using Umbraco.Core.Logging; +using Umbraco.Core.Composing; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.ModelsBuilder.Embedded.Building; +using Umbraco.ModelsBuilder.Embedded.Configuration; +using Umbraco.Web; +using Umbraco.Web.PublishedCache.NuCache; +using Umbraco.Web.Features; + +namespace Umbraco.ModelsBuilder.Embedded.Compose +{ + + + [ComposeBefore(typeof(NuCacheComposer))] + [RuntimeLevel(MinLevel = RuntimeLevel.Run)] + public sealed class ModelsBuilderComposer : ICoreComposer + { + public void Compose(Composition composition) + { + var isLegacyModelsBuilderInstalled = IsLegacyModelsBuilderInstalled(); + + + composition.Configs.Add(() => new ModelsBuilderConfig()); + + if (isLegacyModelsBuilderInstalled) + { + ComposeForLegacyModelsBuilder(composition); + return; + } + + composition.Components().Append(); + composition.Register(Lifetime.Singleton); + + composition.RegisterUnique(); + composition.RegisterUnique(); + composition.RegisterUnique(); + composition.RegisterUnique(); + + if (composition.Configs.ModelsBuilder().ModelsMode == ModelsMode.PureLive) + ComposeForLiveModels(composition); + else if (composition.Configs.ModelsBuilder().EnableFactory) + ComposeForDefaultModelsFactory(composition); + } + + private static bool IsLegacyModelsBuilderInstalled() + { + Assembly legacyMbAssembly = null; + try + { + legacyMbAssembly = Assembly.Load("Umbraco.ModelsBuilder"); + } + catch (System.Exception) + { + //swallow exception, DLL must not be there + } + + return legacyMbAssembly != null; + } + + private void ComposeForLegacyModelsBuilder(Composition composition) + { + composition.Logger.Info("ModelsBuilder.Embedded is disabled, the external ModelsBuilder was detected."); + composition.Components().Append(); + composition.Dashboards().Remove(); + } + + private void ComposeForDefaultModelsFactory(Composition composition) + { + composition.RegisterUnique(factory => + { + var typeLoader = factory.GetInstance(); + var types = typeLoader + .GetTypes() // element models + .Concat(typeLoader.GetTypes()); // content models + return new PublishedModelFactory(types); + }); + } + + private void ComposeForLiveModels(Composition composition) + { + composition.RegisterUnique(); + + // the following would add @using statement in every view so user's don't + // have to do it - however, then noone understands where the @using statement + // comes from, and it cannot be avoided / removed --- DISABLED + // + /* + // no need for @using in views + // note: + // we are NOT using the in-code attribute here, config is required + // because that would require parsing the code... and what if it changes? + // we can AddGlobalImport not sure we can remove one anyways + var modelsNamespace = Configuration.Config.ModelsNamespace; + if (string.IsNullOrWhiteSpace(modelsNamespace)) + modelsNamespace = Configuration.Config.DefaultModelsNamespace; + System.Web.WebPages.Razor.WebPageRazorHost.AddGlobalImport(modelsNamespace); + */ + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderInitializer.cs b/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderInitializer.cs new file mode 100644 index 0000000000..a86669b135 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/Compose/ModelsBuilderInitializer.cs @@ -0,0 +1,28 @@ +using System.Web; +using System.Web.Compilation; +using Umbraco.ModelsBuilder.Embedded.Compose; + +[assembly: PreApplicationStartMethod(typeof(ModelsBuilderInitializer), "Initialize")] + +namespace Umbraco.ModelsBuilder.Embedded.Compose +{ + public static class ModelsBuilderInitializer + { + public static void Initialize() + { + // for some reason, netstandard is missing from BuildManager.ReferencedAssemblies and yet, is part of + // the references that CSharpCompiler receives - in some cases eg when building views - but not when + // using BuildManager to build the PureLive models - where is it coming from? cannot figure it out + + // so... cheating here + + // this is equivalent to adding + // + // to web.config system.web/compilation/assemblies + + var netStandard = ReferencedAssemblies.GetNetStandardAssembly(); + if (netStandard != null) + BuildManager.AddReferencedAssembly(netStandard); + } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/ConfigsExtensions.cs b/src/Umbraco.ModelsBuilder.Embedded/ConfigsExtensions.cs new file mode 100644 index 0000000000..d634547a49 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/ConfigsExtensions.cs @@ -0,0 +1,20 @@ +using Umbraco.Core.Configuration; +using Umbraco.ModelsBuilder.Embedded.Configuration; + +namespace Umbraco.ModelsBuilder.Embedded +{ + /// + /// Provides extension methods for the class. + /// + public static class ConfigsExtensions + { + /// + /// Gets the models builder configuration. + /// + /// Getting the models builder configuration freezes its state, + /// and any attempt at modifying the configuration using the Setup method + /// will be ignored. + public static IModelsBuilderConfig ModelsBuilder(this Configs configs) + => configs.GetConfig(); + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/Configuration/IModelsBuilderConfig.cs b/src/Umbraco.ModelsBuilder.Embedded/Configuration/IModelsBuilderConfig.cs new file mode 100644 index 0000000000..7e96aec60e --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/Configuration/IModelsBuilderConfig.cs @@ -0,0 +1,15 @@ +namespace Umbraco.ModelsBuilder.Embedded.Configuration +{ + public interface IModelsBuilderConfig + { + bool Enable { get; } + bool AcceptUnsafeModelsDirectory { get; } + int DebugLevel { get; } + bool EnableFactory { get; } + bool FlagOutOfDateModels { get; } + bool IsDebug { get; } + string ModelsDirectory { get; } + ModelsMode ModelsMode { get; } + string ModelsNamespace { get; } + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsBuilderConfig.cs b/src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsBuilderConfig.cs new file mode 100644 index 0000000000..179fcecfcb --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsBuilderConfig.cs @@ -0,0 +1,226 @@ +using System; +using System.Configuration; +using System.IO; +using System.Threading; +using System.Web.Configuration; +using Umbraco.Core; +using Umbraco.Core.IO; + +namespace Umbraco.ModelsBuilder.Embedded.Configuration +{ + /// + /// Represents the models builder configuration. + /// + public class ModelsBuilderConfig : IModelsBuilderConfig + { + private const string Prefix = "Umbraco.ModelsBuilder."; + private object _modelsModelLock; + private bool _modelsModelConfigured; + private ModelsMode _modelsMode; + private object _flagOutOfDateModelsLock; + private bool _flagOutOfDateModelsConfigured; + private bool _flagOutOfDateModels; + public const string DefaultModelsNamespace = "Umbraco.Web.PublishedModels"; + public const string DefaultModelsDirectory = "~/App_Data/Models"; + + /// + /// Initializes a new instance of the class. + /// + public ModelsBuilderConfig() + { + // giant kill switch, default: false + // must be explicitely set to true for anything else to happen + Enable = ConfigurationManager.AppSettings[Prefix + "Enable"] == "true"; + + // ensure defaults are initialized for tests + ModelsNamespace = DefaultModelsNamespace; + ModelsDirectory = IOHelper.MapPath(DefaultModelsDirectory); + DebugLevel = 0; + + // stop here, everything is false + if (!Enable) return; + + // default: false + AcceptUnsafeModelsDirectory = ConfigurationManager.AppSettings[Prefix + "AcceptUnsafeModelsDirectory"].InvariantEquals("true"); + + // default: true + EnableFactory = !ConfigurationManager.AppSettings[Prefix + "EnableFactory"].InvariantEquals("false"); + + // default: initialized above with DefaultModelsNamespace const + var value = ConfigurationManager.AppSettings[Prefix + "ModelsNamespace"]; + if (!string.IsNullOrWhiteSpace(value)) + ModelsNamespace = value; + + // default: initialized above with DefaultModelsDirectory const + value = ConfigurationManager.AppSettings[Prefix + "ModelsDirectory"]; + if (!string.IsNullOrWhiteSpace(value)) + { + var root = IOHelper.MapPath("~/"); + if (root == null) + throw new ConfigurationErrorsException("Could not determine root directory."); + + // GetModelsDirectory will ensure that the path is safe + ModelsDirectory = GetModelsDirectory(root, value, AcceptUnsafeModelsDirectory); + } + + // default: 0 + value = ConfigurationManager.AppSettings[Prefix + "DebugLevel"]; + if (!string.IsNullOrWhiteSpace(value)) + { + if (!int.TryParse(value, out var debugLevel)) + throw new ConfigurationErrorsException($"Invalid debug level \"{value}\"."); + DebugLevel = debugLevel; + } + + } + + /// + /// Initializes a new instance of the class. + /// + public ModelsBuilderConfig( + bool enable = false, + ModelsMode modelsMode = ModelsMode.Nothing, + string modelsNamespace = null, + bool enableFactory = true, + bool flagOutOfDateModels = true, + string modelsDirectory = null, + bool acceptUnsafeModelsDirectory = false, + int debugLevel = 0) + { + Enable = enable; + _modelsMode = modelsMode; + + ModelsNamespace = string.IsNullOrWhiteSpace(modelsNamespace) ? DefaultModelsNamespace : modelsNamespace; + EnableFactory = enableFactory; + _flagOutOfDateModels = flagOutOfDateModels; + ModelsDirectory = string.IsNullOrWhiteSpace(modelsDirectory) ? DefaultModelsDirectory : modelsDirectory; + AcceptUnsafeModelsDirectory = acceptUnsafeModelsDirectory; + DebugLevel = debugLevel; + } + + // internal for tests + internal static string GetModelsDirectory(string root, string config, bool acceptUnsafe) + { + // making sure it is safe, ie under the website root, + // unless AcceptUnsafeModelsDirectory and then everything is OK. + + if (!Path.IsPathRooted(root)) + throw new ConfigurationErrorsException($"Root is not rooted \"{root}\"."); + + if (config.StartsWith("~/")) + { + var dir = Path.Combine(root, config.TrimStart("~/")); + + // sanitize - GetFullPath will take care of any relative + // segments in path, eg '../../foo.tmp' - it may throw a SecurityException + // if the combined path reaches illegal parts of the filesystem + dir = Path.GetFullPath(dir); + root = Path.GetFullPath(root); + + if (!dir.StartsWith(root) && !acceptUnsafe) + throw new ConfigurationErrorsException($"Invalid models directory \"{config}\"."); + + return dir; + } + + if (acceptUnsafe) + return Path.GetFullPath(config); + + throw new ConfigurationErrorsException($"Invalid models directory \"{config}\"."); + } + + /// + /// Gets a value indicating whether the whole models experience is enabled. + /// + /// + /// If this is false then absolutely nothing happens. + /// Default value is false which means that unless we have this setting, nothing happens. + /// + public bool Enable { get; } + + /// + /// Gets the models mode. + /// + public ModelsMode ModelsMode => + LazyInitializer.EnsureInitialized(ref _modelsMode, ref _modelsModelConfigured, ref _modelsModelLock, () => + { + // mode + var modelsMode = ConfigurationManager.AppSettings[Prefix + "ModelsMode"]; + if (string.IsNullOrWhiteSpace(modelsMode)) return ModelsMode.Nothing; //default + switch (modelsMode) + { + case nameof(ModelsMode.Nothing): + return ModelsMode.Nothing; + case nameof(ModelsMode.PureLive): + return ModelsMode.PureLive; + case nameof(ModelsMode.AppData): + return ModelsMode.AppData; + case nameof(ModelsMode.LiveAppData): + return ModelsMode.LiveAppData; + default: + throw new ConfigurationErrorsException($"ModelsMode \"{modelsMode}\" is not a valid mode." + " Note that modes are case-sensitive. Possible values are: " + string.Join(", ", Enum.GetNames(typeof(ModelsMode)))); + } + }); + + /// + /// Gets a value indicating whether system.web/compilation/@debug is true. + /// + public bool IsDebug + { + get + { + var section = (CompilationSection)ConfigurationManager.GetSection("system.web/compilation"); + return section != null && section.Debug; + } + } + + /// + /// Gets the models namespace. + /// + /// That value could be overriden by other (attribute in user's code...). Return default if no value was supplied. + public string ModelsNamespace { get; } + + /// + /// Gets a value indicating whether we should enable the models factory. + /// + /// Default value is true because no factory is enabled by default in Umbraco. + public bool EnableFactory { get; } + + /// + /// Gets a value indicating whether we should flag out-of-date models. + /// + /// Models become out-of-date when data types or content types are updated. When this + /// setting is activated the ~/App_Data/Models/ood.txt file is then created. When models are + /// generated through the dashboard, the files is cleared. Default value is false. + public bool FlagOutOfDateModels + => LazyInitializer.EnsureInitialized(ref _flagOutOfDateModels, ref _flagOutOfDateModelsConfigured, ref _flagOutOfDateModelsLock, () => + { + var flagOutOfDateModels = !ConfigurationManager.AppSettings[Prefix + "FlagOutOfDateModels"].InvariantEquals("false"); + if (ModelsMode == ModelsMode.Nothing || ModelsMode.IsLive()) + { + flagOutOfDateModels = false; + } + + return flagOutOfDateModels; + }); + + /// + /// Gets the models directory. + /// + /// Default is ~/App_Data/Models but that can be changed. + public string ModelsDirectory { get; } + + /// + /// Gets a value indicating whether to accept an unsafe value for ModelsDirectory. + /// + /// An unsafe value is an absolute path, or a relative path pointing outside + /// of the website root. + public bool AcceptUnsafeModelsDirectory { get; } + + /// + /// Gets a value indicating the debug log level. + /// + /// 0 means minimal (safe on live site), anything else means more and more details (maybe not safe). + public int DebugLevel { get; } + } +} diff --git a/src/Umbraco.ModelsBuilder/Configuration/ModelsMode.cs b/src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsMode.cs similarity index 56% rename from src/Umbraco.ModelsBuilder/Configuration/ModelsMode.cs rename to src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsMode.cs index e04c4dee90..e0286fdab1 100644 --- a/src/Umbraco.ModelsBuilder/Configuration/ModelsMode.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsMode.cs @@ -1,4 +1,4 @@ -namespace Umbraco.ModelsBuilder.Configuration +namespace Umbraco.ModelsBuilder.Embedded.Configuration { /// /// Defines the models generation modes. @@ -8,7 +8,7 @@ /// /// Do not generate models. /// - Nothing = 0, // default value + Nothing = 0, // default value /// /// Generate models in memory. @@ -31,22 +31,6 @@ /// /// Generation can be triggered from the dashboard. The app does not restart. /// Models are not compiled and thus are not available to the project. - LiveAppData, - - /// - /// Generates models in AppData and compiles them into a Dll into ~/bin (the app restarts). - /// When: generation is triggered. - /// - /// Generation can be triggered from the dashboard. The app does restart. Models - /// are available to the entire project. - Dll, - - /// - /// Generates models in AppData and compiles them into a Dll into ~/bin (the app restarts). - /// When: a content type change occurs, or generation is triggered. - /// - /// Generation can be triggered from the dashboard. The app does restart. Models - /// are available to the entire project. - LiveDll + LiveAppData } } diff --git a/src/Umbraco.ModelsBuilder/Configuration/ModelsModeExtensions.cs b/src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsModeExtensions.cs similarity index 61% rename from src/Umbraco.ModelsBuilder/Configuration/ModelsModeExtensions.cs rename to src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsModeExtensions.cs index be609c0548..be638729ea 100644 --- a/src/Umbraco.ModelsBuilder/Configuration/ModelsModeExtensions.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/Configuration/ModelsModeExtensions.cs @@ -1,4 +1,4 @@ -namespace Umbraco.ModelsBuilder.Configuration +namespace Umbraco.ModelsBuilder.Embedded.Configuration { /// /// Provides extensions for the enumeration. @@ -12,7 +12,6 @@ { return modelsMode == ModelsMode.PureLive - || modelsMode == ModelsMode.LiveDll || modelsMode == ModelsMode.LiveAppData; } @@ -22,18 +21,7 @@ public static bool IsLiveNotPure(this ModelsMode modelsMode) { return - modelsMode == ModelsMode.LiveDll - || modelsMode == ModelsMode.LiveAppData; - } - - /// - /// Gets a value indicating whether the mode is [Live]Dll. - /// - public static bool IsAnyDll(this ModelsMode modelsMode) - { - return - modelsMode == ModelsMode.Dll - || modelsMode == ModelsMode.LiveDll; + modelsMode == ModelsMode.LiveAppData; } /// @@ -42,10 +30,8 @@ public static bool SupportsExplicitGeneration(this ModelsMode modelsMode) { return - modelsMode == ModelsMode.Dll - || modelsMode == ModelsMode.LiveDll - || modelsMode == ModelsMode.AppData + modelsMode == ModelsMode.AppData || modelsMode == ModelsMode.LiveAppData; } } -} \ No newline at end of file +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/HashCombiner.cs b/src/Umbraco.ModelsBuilder.Embedded/HashCombiner.cs similarity index 76% rename from src/Umbraco.ModelsBuilder/Umbraco/HashCombiner.cs rename to src/Umbraco.ModelsBuilder.Embedded/HashCombiner.cs index e11662eb24..1c1fca6f73 100644 --- a/src/Umbraco.ModelsBuilder/Umbraco/HashCombiner.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/HashCombiner.cs @@ -1,17 +1,17 @@ using System; using System.Globalization; -namespace Umbraco.ModelsBuilder.Umbraco +namespace Umbraco.ModelsBuilder.Embedded { // because, of course, it's internal in Umbraco // see also System.Web.Util.HashCodeCombiner - class HashCombiner + internal class HashCombiner { private long _combinedHash = 5381L; public void Add(int i) { - _combinedHash = ((_combinedHash << 5) + _combinedHash) ^ i; + _combinedHash = (_combinedHash << 5) + _combinedHash ^ i; } public void Add(object o) @@ -27,7 +27,7 @@ namespace Umbraco.ModelsBuilder.Umbraco public void Add(string s) { if (s == null) return; - Add((StringComparer.InvariantCulture).GetHashCode(s)); + Add(StringComparer.InvariantCulture.GetHashCode(s)); } public string GetCombinedHashCode() diff --git a/src/Umbraco.ModelsBuilder/ImplementPropertyTypeAttribute.cs b/src/Umbraco.ModelsBuilder.Embedded/ImplementPropertyTypeAttribute.cs similarity index 69% rename from src/Umbraco.ModelsBuilder/ImplementPropertyTypeAttribute.cs rename to src/Umbraco.ModelsBuilder.Embedded/ImplementPropertyTypeAttribute.cs index c5d8f8cad4..b7b2695a08 100644 --- a/src/Umbraco.ModelsBuilder/ImplementPropertyTypeAttribute.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/ImplementPropertyTypeAttribute.cs @@ -1,17 +1,13 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace Umbraco.ModelsBuilder +namespace Umbraco.ModelsBuilder.Embedded { /// /// Indicates that a property implements a given property alias. /// /// And therefore it should not be generated. [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] - public sealed class ImplementPropertyTypeAttribute : Attribute + public class ImplementPropertyTypeAttribute : Attribute { public ImplementPropertyTypeAttribute(string alias) { diff --git a/src/Umbraco.ModelsBuilder.Embedded/LiveModelsProvider.cs b/src/Umbraco.ModelsBuilder.Embedded/LiveModelsProvider.cs new file mode 100644 index 0000000000..333181f27c --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/LiveModelsProvider.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading; +using System.Web.Hosting; +using Umbraco.Core.Logging; +using Umbraco.ModelsBuilder.Embedded.Building; +using Umbraco.ModelsBuilder.Embedded.Configuration; +using Umbraco.Web.Cache; + +namespace Umbraco.ModelsBuilder.Embedded +{ + // supports LiveAppData - but not PureLive + public sealed class LiveModelsProvider + { + private static Mutex _mutex; + private static int _req; + private readonly ILogger _logger; + private readonly IModelsBuilderConfig _config; + private readonly ModelsGenerator _modelGenerator; + private readonly ModelsGenerationError _mbErrors; + + // we do not manage pure live here + internal bool IsEnabled => _config.ModelsMode.IsLiveNotPure(); + + public LiveModelsProvider(ILogger logger, IModelsBuilderConfig config, ModelsGenerator modelGenerator, ModelsGenerationError mbErrors) + { + _logger = logger; + _config = config ?? throw new ArgumentNullException(nameof(config)); + _modelGenerator = modelGenerator; + _mbErrors = mbErrors; + } + + internal void Install() + { + // just be sure + if (!IsEnabled) + return; + + // initialize mutex + // ApplicationId will look like "/LM/W3SVC/1/Root/AppName" + // name is system-wide and must be less than 260 chars + var name = HostingEnvironment.ApplicationID + "/UmbracoLiveModelsProvider"; + + _mutex = new Mutex(false, name); //TODO: Replace this with MainDom? Seems we now have 2x implementations of almost the same thing + + // anything changes, and we want to re-generate models. + ContentTypeCacheRefresher.CacheUpdated += RequestModelsGeneration; + DataTypeCacheRefresher.CacheUpdated += RequestModelsGeneration; + + // at the end of a request since we're restarting the pool + // NOTE - this does NOT trigger - see module below + //umbracoApplication.EndRequest += GenerateModelsIfRequested; + } + + // NOTE + // Using HttpContext Items fails because CacheUpdated triggers within + // some asynchronous backend task where we seem to have no HttpContext. + + // So we use a static (non request-bound) var to register that models + // need to be generated. Could be by another request. Anyway. We could + // have collisions but... you know the risk. + + private void RequestModelsGeneration(object sender, EventArgs args) + { + //HttpContext.Current.Items[this] = true; + _logger.Debug("Requested to generate models."); + Interlocked.Exchange(ref _req, 1); + } + + public void GenerateModelsIfRequested(object sender, EventArgs args) + { + //if (HttpContext.Current.Items[this] == null) return; + if (Interlocked.Exchange(ref _req, 0) == 0) return; + + // cannot use a simple lock here because we don't want another AppDomain + // to generate while we do... and there could be 2 AppDomains if the app restarts. + + try + { + _logger.Debug("Generate models..."); + const int timeout = 2 * 60 * 1000; // 2 mins + _mutex.WaitOne(timeout); // wait until it is safe, and acquire + _logger.Info("Generate models now."); + GenerateModels(); + _mbErrors.Clear(); + _logger.Info("Generated."); + } + catch (TimeoutException) + { + _logger.Warn("Timeout, models were NOT generated."); + } + catch (Exception e) + { + _mbErrors.Report("Failed to build Live models.", e); + _logger.Error("Failed to generate models.", e); + } + finally + { + _mutex.ReleaseMutex(); // release + } + } + + private void GenerateModels() + { + // EnableDllModels will recycle the app domain - but this request will end properly + _modelGenerator.GenerateModels(); + } + + + } +} diff --git a/src/Umbraco.ModelsBuilder.Embedded/LiveModelsProviderModule.cs b/src/Umbraco.ModelsBuilder.Embedded/LiveModelsProviderModule.cs new file mode 100644 index 0000000000..678ff241b0 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/LiveModelsProviderModule.cs @@ -0,0 +1,47 @@ +using System; +using System.Web; +using Umbraco.Core; +using Umbraco.Core.Composing; +using Umbraco.ModelsBuilder.Embedded; + +// will install only if configuration says it needs to be installed +[assembly: PreApplicationStartMethod(typeof(LiveModelsProviderModule), "Install")] + +namespace Umbraco.ModelsBuilder.Embedded +{ + // have to do this because it's the only way to subscribe to EndRequest, + // module is installed by assembly attribute at the top of this file + public class LiveModelsProviderModule : IHttpModule + { + private static LiveModelsProvider _liveModelsProvider; + + public void Init(HttpApplication app) + { + app.EndRequest += App_EndRequest; + } + + private void App_EndRequest(object sender, EventArgs e) + { + if (((HttpApplication)sender).Request.Url.IsClientSideRequest()) + return; + + // here we're using "Current." since we're in a module, it is possible in a round about way to inject into a module but for now we'll just use Current + if (_liveModelsProvider == null) + _liveModelsProvider = Current.Factory.TryGetInstance(); // will be null in upgrade mode or if embedded MB is disabled + + if (_liveModelsProvider?.IsEnabled ?? false) + _liveModelsProvider.GenerateModelsIfRequested(sender, e); + } + + public void Dispose() + { + // nothing + } + + public static void Install() + { + // always - don't read config in PreApplicationStartMethod + HttpApplication.RegisterModule(typeof(LiveModelsProviderModule)); + } + } +} diff --git a/src/Umbraco.ModelsBuilder/ModelsBuilderAssemblyAttribute.cs b/src/Umbraco.ModelsBuilder.Embedded/ModelsBuilderAssemblyAttribute.cs similarity index 95% rename from src/Umbraco.ModelsBuilder/ModelsBuilderAssemblyAttribute.cs rename to src/Umbraco.ModelsBuilder.Embedded/ModelsBuilderAssemblyAttribute.cs index ed956852f8..7570c0b5b2 100644 --- a/src/Umbraco.ModelsBuilder/ModelsBuilderAssemblyAttribute.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/ModelsBuilderAssemblyAttribute.cs @@ -1,6 +1,6 @@ using System; -namespace Umbraco.ModelsBuilder +namespace Umbraco.ModelsBuilder.Embedded { /// /// Indicates that an Assembly is a Models Builder assembly. diff --git a/src/Umbraco.ModelsBuilder.Embedded/ModelsBuilderDashboard.cs b/src/Umbraco.ModelsBuilder.Embedded/ModelsBuilderDashboard.cs new file mode 100644 index 0000000000..b8b1945f32 --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/ModelsBuilderDashboard.cs @@ -0,0 +1,19 @@ +using System; +using Umbraco.Core.Composing; +using Umbraco.Core.Dashboards; + +namespace Umbraco.ModelsBuilder.Embedded +{ + [Weight(40)] + public class ModelsBuilderDashboard : IDashboard + { + public string Alias => "settingsModelsBuilder"; + + public string[] Sections => new [] { "settings" }; + + public string View => "views/dashboard/settings/modelsbuildermanagement.html"; + + public IAccessRule[] AccessRules => Array.Empty(); + } + +} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/ModelsGenerationError.cs b/src/Umbraco.ModelsBuilder.Embedded/ModelsGenerationError.cs similarity index 68% rename from src/Umbraco.ModelsBuilder/Umbraco/ModelsGenerationError.cs rename to src/Umbraco.ModelsBuilder.Embedded/ModelsGenerationError.cs index 7102190b5e..a692f633a5 100644 --- a/src/Umbraco.ModelsBuilder/Umbraco/ModelsGenerationError.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/ModelsGenerationError.cs @@ -1,14 +1,20 @@ using System; using System.IO; using System.Text; -using Umbraco.Core.Configuration; -using Umbraco.ModelsBuilder.Configuration; +using Umbraco.ModelsBuilder.Embedded.Configuration; -namespace Umbraco.ModelsBuilder.Umbraco +namespace Umbraco.ModelsBuilder.Embedded { - internal static class ModelsGenerationError + public sealed class ModelsGenerationError { - public static void Clear() + private readonly IModelsBuilderConfig _config; + + public ModelsGenerationError(IModelsBuilderConfig config) + { + _config = config; + } + + public void Clear() { var errFile = GetErrFile(); if (errFile == null) return; @@ -17,7 +23,7 @@ namespace Umbraco.ModelsBuilder.Umbraco File.Delete(errFile); } - public static void Report(string message, Exception e) + public void Report(string message, Exception e) { var errFile = GetErrFile(); if (errFile == null) return; @@ -33,7 +39,7 @@ namespace Umbraco.ModelsBuilder.Umbraco File.WriteAllText(errFile, sb.ToString()); } - public static string GetLastError() + public string GetLastError() { var errFile = GetErrFile(); if (errFile == null) return null; @@ -48,9 +54,9 @@ namespace Umbraco.ModelsBuilder.Umbraco } } - private static string GetErrFile() + private string GetErrFile() { - var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + var modelsDirectory = _config.ModelsDirectory; if (!Directory.Exists(modelsDirectory)) return null; diff --git a/src/Umbraco.ModelsBuilder/Umbraco/OutOfDateModelsStatus.cs b/src/Umbraco.ModelsBuilder.Embedded/OutOfDateModelsStatus.cs similarity index 55% rename from src/Umbraco.ModelsBuilder/Umbraco/OutOfDateModelsStatus.cs rename to src/Umbraco.ModelsBuilder.Embedded/OutOfDateModelsStatus.cs index a047f21edb..5425c31c77 100644 --- a/src/Umbraco.ModelsBuilder/Umbraco/OutOfDateModelsStatus.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/OutOfDateModelsStatus.cs @@ -1,55 +1,58 @@ -using System; -using System.IO; -using System.Web.Hosting; -using Umbraco.Core; -using Umbraco.Core.Configuration; -using Umbraco.ModelsBuilder.Configuration; +using System.IO; +using Umbraco.ModelsBuilder.Embedded.Configuration; using Umbraco.Web.Cache; -namespace Umbraco.ModelsBuilder.Umbraco +namespace Umbraco.ModelsBuilder.Embedded { public sealed class OutOfDateModelsStatus { - internal static void Install() + private readonly IModelsBuilderConfig _config; + + public OutOfDateModelsStatus(IModelsBuilderConfig config) + { + _config = config; + } + + internal void Install() { // just be sure - if (UmbracoConfig.For.ModelsBuilder().FlagOutOfDateModels == false) + if (_config.FlagOutOfDateModels == false) return; ContentTypeCacheRefresher.CacheUpdated += (sender, args) => Write(); DataTypeCacheRefresher.CacheUpdated += (sender, args) => Write(); } - private static string GetFlagPath() + private string GetFlagPath() { - var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + var modelsDirectory = _config.ModelsDirectory; if (!Directory.Exists(modelsDirectory)) Directory.CreateDirectory(modelsDirectory); return Path.Combine(modelsDirectory, "ood.flag"); } - private static void Write() + private void Write() { var path = GetFlagPath(); if (path == null || File.Exists(path)) return; File.WriteAllText(path, "THIS FILE INDICATES THAT MODELS ARE OUT-OF-DATE\n\n"); } - public static void Clear() + public void Clear() { - if (UmbracoConfig.For.ModelsBuilder().FlagOutOfDateModels == false) return; + if (_config.FlagOutOfDateModels == false) return; var path = GetFlagPath(); if (path == null || !File.Exists(path)) return; File.Delete(path); } - public static bool IsEnabled => UmbracoConfig.For.ModelsBuilder().FlagOutOfDateModels; + public bool IsEnabled => _config.FlagOutOfDateModels; - public static bool IsOutOfDate + public bool IsOutOfDate { get { - if (UmbracoConfig.For.ModelsBuilder().FlagOutOfDateModels == false) return false; + if (_config.FlagOutOfDateModels == false) return false; var path = GetFlagPath(); return path != null && File.Exists(path); } diff --git a/src/Umbraco.ModelsBuilder.Embedded/Properties/AssemblyInfo.cs b/src/Umbraco.ModelsBuilder.Embedded/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..68c149adde --- /dev/null +++ b/src/Umbraco.ModelsBuilder.Embedded/Properties/AssemblyInfo.cs @@ -0,0 +1,13 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Umbraco.ModelsBuilder")] +[assembly: AssemblyDescription("Umbraco ModelsBuilder")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyProduct("Umbraco CMS")] + +[assembly: ComVisible(false)] +[assembly: Guid("52ac0ba8-a60e-4e36-897b-e8b97a54ed1c")] + +[assembly: InternalsVisibleTo("Umbraco.Tests")] diff --git a/src/Umbraco.ModelsBuilder/PublishedElementExtensions.cs b/src/Umbraco.ModelsBuilder.Embedded/PublishedElementExtensions.cs similarity index 74% rename from src/Umbraco.ModelsBuilder/PublishedElementExtensions.cs rename to src/Umbraco.ModelsBuilder.Embedded/PublishedElementExtensions.cs index f3320b5dfb..8a0a688942 100644 --- a/src/Umbraco.ModelsBuilder/PublishedElementExtensions.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/PublishedElementExtensions.cs @@ -2,9 +2,12 @@ using System.Linq.Expressions; using System.Reflection; using Umbraco.Core.Models.PublishedContent; -using Umbraco.Web; +using Umbraco.ModelsBuilder; +using Umbraco.ModelsBuilder.Embedded; -namespace Umbraco.ModelsBuilder +// same namespace as original Umbraco.Web PublishedElementExtensions +// ReSharper disable once CheckNamespace +namespace Umbraco.Web { /// /// Provides extension methods to models. @@ -14,13 +17,14 @@ namespace Umbraco.ModelsBuilder /// /// Gets the value of a property. /// - public static TValue Value(this TModel model, Expression> property, string culture = ".", string segment = ".") + public static TValue ValueFor(this TModel model, Expression> property, string culture = null, string segment = null, Fallback fallback = default, TValue defaultValue = default) where TModel : IPublishedElement { var alias = GetAlias(model, property); - return model.Value(alias, culture, segment); + return model.Value(alias, culture, segment, fallback, defaultValue); } + // fixme that one should be public so ppl can use it private static string GetAlias(TModel model, Expression> property) { if (property.NodeType != ExpressionType.Lambda) @@ -41,7 +45,7 @@ namespace Umbraco.ModelsBuilder var attribute = member.GetCustomAttribute(); if (attribute == null) throw new InvalidOperationException("Property is not marked with ImplementPropertyType attribute."); - + return attribute.Alias; } } diff --git a/src/Umbraco.ModelsBuilder/Umbraco/PublishedModelUtility.cs b/src/Umbraco.ModelsBuilder.Embedded/PublishedModelUtility.cs similarity index 77% rename from src/Umbraco.ModelsBuilder/Umbraco/PublishedModelUtility.cs rename to src/Umbraco.ModelsBuilder.Embedded/PublishedModelUtility.cs index c70e8a3b65..8a6ed83ce9 100644 --- a/src/Umbraco.ModelsBuilder/Umbraco/PublishedModelUtility.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/PublishedModelUtility.cs @@ -1,11 +1,17 @@ using System; using System.Linq; using System.Linq.Expressions; -using Umbraco.Web.Composing; using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web.Composing; -namespace Umbraco.ModelsBuilder.Umbraco +namespace Umbraco.ModelsBuilder.Embedded { + /// + /// This is called from within the generated model classes + /// + /// + /// DO NOT REMOVE - although there are not code references this is used directly by the generated models. + /// public static class PublishedModelUtility { // looks safer but probably useless... ppl should not call these methods directly @@ -24,7 +30,7 @@ namespace Umbraco.ModelsBuilder.Umbraco // // etc... //} - public static PublishedContentType GetModelContentType(PublishedItemType itemType, string alias) + public static IPublishedContentType GetModelContentType(PublishedItemType itemType, string alias) { var facade = Current.UmbracoContext.PublishedSnapshot; // fixme inject! switch (itemType) @@ -40,8 +46,8 @@ namespace Umbraco.ModelsBuilder.Umbraco } } - public static PublishedPropertyType GetModelPropertyType(PublishedContentType contentType, Expression> selector) - //where TModel : PublishedContentModel // fixme PublishedContentModel _or_ PublishedElementModel + public static IPublishedPropertyType GetModelPropertyType(IPublishedContentType contentType, Expression> selector) + //where TModel : PublishedContentModel // fixme PublishedContentModel _or_ PublishedElementModel { // fixme therefore, missing a check on TModel here @@ -54,7 +60,7 @@ namespace Umbraco.ModelsBuilder.Umbraco // see note above : accepted risk... var attr = expr.Member - .GetCustomAttributes(typeof (ImplementPropertyTypeAttribute), false) + .GetCustomAttributes(typeof(ImplementPropertyTypeAttribute), false) .OfType() .SingleOrDefault(); diff --git a/src/Umbraco.ModelsBuilder/Umbraco/PureLiveModelFactory.cs b/src/Umbraco.ModelsBuilder.Embedded/PureLiveModelFactory.cs similarity index 72% rename from src/Umbraco.ModelsBuilder/Umbraco/PureLiveModelFactory.cs rename to src/Umbraco.ModelsBuilder.Embedded/PureLiveModelFactory.cs index 9558c0140e..8e8a19c729 100644 --- a/src/Umbraco.ModelsBuilder/Umbraco/PureLiveModelFactory.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/PureLiveModelFactory.cs @@ -3,59 +3,60 @@ using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Linq.Expressions; using System.Reflection; using System.Reflection.Emit; using System.Text; using System.Text.RegularExpressions; using System.Threading; +using System.Web; using System.Web.Compilation; using System.Web.Hosting; using System.Web.WebPages.Razor; using Umbraco.Core; -using Umbraco.Core.Configuration; using Umbraco.Core.Logging; -using Umbraco.Core.Models; using Umbraco.Core.Models.PublishedContent; -using Umbraco.Web.Cache; -using Umbraco.ModelsBuilder.Building; -using Umbraco.ModelsBuilder.Configuration; +using Umbraco.ModelsBuilder.Embedded.Building; +using Umbraco.ModelsBuilder.Embedded.Configuration; using File = System.IO.File; -namespace Umbraco.ModelsBuilder.Umbraco +namespace Umbraco.ModelsBuilder.Embedded { - internal class PureLiveModelFactory : IPublishedModelFactory, IRegisteredObject + internal class PureLiveModelFactory : ILivePublishedModelFactory, IRegisteredObject { private Assembly _modelsAssembly; private Infos _infos = new Infos { ModelInfos = null, ModelTypeMap = new Dictionary() }; private readonly ReaderWriterLockSlim _locker = new ReaderWriterLockSlim(); private volatile bool _hasModels; // volatile 'cos reading outside lock private bool _pendingRebuild; - private readonly ProfilingLogger _logger; + private readonly IProfilingLogger _logger; private readonly FileSystemWatcher _watcher; private int _ver, _skipver; private readonly int _debugLevel; private BuildManager _theBuildManager; - private readonly Lazy _umbracoServices; + private readonly Lazy _umbracoServices; // fixme: this is because of circular refs :( private UmbracoServices UmbracoServices => _umbracoServices.Value; private static readonly Regex AssemblyVersionRegex = new Regex("AssemblyVersion\\(\"[0-9]+.[0-9]+.[0-9]+.[0-9]+\"\\)", RegexOptions.Compiled); private const string ProjVirt = "~/App_Data/Models/all.generated.cs"; private static readonly string[] OurFiles = { "models.hash", "models.generated.cs", "all.generated.cs", "all.dll.path", "models.err" }; - public PureLiveModelFactory(Lazy umbracoServices, ProfilingLogger logger) + private readonly IModelsBuilderConfig _config; + private readonly ModelsGenerationError _errors; + + public PureLiveModelFactory(Lazy umbracoServices, IProfilingLogger logger, IModelsBuilderConfig config) { _umbracoServices = umbracoServices; _logger = logger; + _config = config; + _errors = new ModelsGenerationError(config); _ver = 1; // zero is for when we had no version _skipver = -1; // nothing to skip - ContentTypeCacheRefresher.CacheUpdated += (sender, args) => ResetModels(); - DataTypeCacheRefresher.CacheUpdated += (sender, args) => ResetModels(); + RazorBuildProvider.CodeGenerationStarted += RazorBuildProvider_CodeGenerationStarted; if (!HostingEnvironment.IsHosted) return; - var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + var modelsDirectory = _config.ModelsDirectory; if (!Directory.Exists(modelsDirectory)) Directory.CreateDirectory(modelsDirectory); @@ -68,9 +69,23 @@ namespace Umbraco.ModelsBuilder.Umbraco _watcher.EnableRaisingEvents = true; // get it here, this need to be fast - _debugLevel = UmbracoConfig.For.ModelsBuilder().DebugLevel; + _debugLevel = _config.DebugLevel; } + #region ILivePublishedModelFactory + + /// + public object SyncRoot { get; } = new object(); + + /// + public void Refresh() + { + ResetModels(); + EnsureModels(); + } + + #endregion + #region IPublishedModelFactory public IPublishedElement CreateModel(IPublishedElement element) @@ -84,7 +99,7 @@ namespace Umbraco.ModelsBuilder.Umbraco var contentTypeAlias = element.ContentType.Alias; // lookup model constructor (else null) - infos.TryGetValue(contentTypeAlias, out ModelInfo info); + infos.TryGetValue(contentTypeAlias, out var info); // create model return info == null ? element : info.Ctor(element); @@ -115,7 +130,7 @@ namespace Umbraco.ModelsBuilder.Umbraco if (ctor != null) return ctor(); var listType = typeof(List<>).MakeGenericType(modelInfo.ModelType); - ctor = modelInfo.ListCtor = ReflectionUtilities.EmitCtor>(declaring: listType); + ctor = modelInfo.ListCtor = ReflectionUtilities.EmitConstructor>(declaring: listType); return ctor(); } @@ -163,7 +178,7 @@ namespace Umbraco.ModelsBuilder.Umbraco if (_modelsAssembly == null) return; if (_debugLevel > 0) - _logger.Logger.Debug("RazorBuildProvider.CodeGenerationStarted"); + _logger.Debug("RazorBuildProvider.CodeGenerationStarted"); if (!(sender is RazorBuildProvider provider)) return; // add the assembly, and add a dependency to a text file that will change on each @@ -182,7 +197,7 @@ namespace Umbraco.ModelsBuilder.Umbraco // tells the factory that it should build a new generation of models private void ResetModels() { - _logger.Logger.Debug("Resetting models."); + _logger.Debug("Resetting models."); try { @@ -190,6 +205,19 @@ namespace Umbraco.ModelsBuilder.Umbraco _hasModels = false; _pendingRebuild = true; + + var modelsDirectory = _config.ModelsDirectory; + if (!Directory.Exists(modelsDirectory)) + Directory.CreateDirectory(modelsDirectory); + + // clear stuff + var modelsHashFile = Path.Combine(modelsDirectory, "models.hash"); + //var modelsSrcFile = Path.Combine(modelsDirectory, "models.generated.cs"); + //var projFile = Path.Combine(modelsDirectory, "all.generated.cs"); + var dllPathFile = Path.Combine(modelsDirectory, "all.dll.path"); + + if (File.Exists(dllPathFile)) File.Delete(dllPathFile); + if (File.Exists(modelsHashFile)) File.Delete(modelsHashFile); } finally { @@ -204,10 +232,10 @@ namespace Umbraco.ModelsBuilder.Umbraco get { if (_theBuildManager != null) return _theBuildManager; - var prop = typeof (BuildManager).GetProperty("TheBuildManager", BindingFlags.NonPublic | BindingFlags.Static); + var prop = typeof(BuildManager).GetProperty("TheBuildManager", BindingFlags.NonPublic | BindingFlags.Static); if (prop == null) throw new InvalidOperationException("Could not get BuildManager.TheBuildManager property."); - _theBuildManager = (BuildManager) prop.GetValue(null); + _theBuildManager = (BuildManager)prop.GetValue(null); return _theBuildManager; } } @@ -216,7 +244,7 @@ namespace Umbraco.ModelsBuilder.Umbraco internal Infos EnsureModels() { if (_debugLevel > 0) - _logger.Logger.Debug("Ensuring models."); + _logger.Debug("Ensuring models."); // don't use an upgradeable lock here because only 1 thread at a time could enter it try @@ -264,15 +292,15 @@ namespace Umbraco.ModelsBuilder.Umbraco var types = assembly.ExportedTypes.Where(x => x.Inherits() || x.Inherits()); _infos = RegisterModels(types); - ModelsGenerationError.Clear(); + _errors.Clear(); } catch (Exception e) { try { - _logger.Logger.Error("Failed to build models.", e); - _logger.Logger.Warn("Running without models."); // be explicit - ModelsGenerationError.Report("Failed to build PureLive models.", e); + _logger.Error("Failed to build models.", e); + _logger.Warn("Running without models."); // be explicit + _errors.Report("Failed to build PureLive models.", e); } finally { @@ -300,19 +328,12 @@ namespace Umbraco.ModelsBuilder.Umbraco private Assembly GetModelsAssembly(bool forceRebuild) { - var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + var modelsDirectory = _config.ModelsDirectory; if (!Directory.Exists(modelsDirectory)) Directory.CreateDirectory(modelsDirectory); - // must filter out *.generated.cs because we haven't deleted them yet! - var ourFiles = Directory.Exists(modelsDirectory) - ? Directory.GetFiles(modelsDirectory, "*.cs") - .Where(x => !x.EndsWith(".generated.cs")) - .ToDictionary(x => x, File.ReadAllText) - : new Dictionary(); - var typeModels = UmbracoServices.GetAllTypes(); - var currentHash = HashHelper.Hash(ourFiles, typeModels); + var currentHash = TypeModelHasher.Hash(typeModels); var modelsHashFile = Path.Combine(modelsDirectory, "models.hash"); var modelsSrcFile = Path.Combine(modelsDirectory, "models.generated.cs"); var projFile = Path.Combine(modelsDirectory, "all.generated.cs"); @@ -323,31 +344,48 @@ namespace Umbraco.ModelsBuilder.Umbraco if (!forceRebuild) { - _logger.Logger.Debug("Looking for cached models."); + _logger.Debug("Looking for cached models."); if (File.Exists(modelsHashFile) && File.Exists(projFile)) { var cachedHash = File.ReadAllText(modelsHashFile); if (currentHash != cachedHash) { - _logger.Logger.Debug("Found obsolete cached models."); + _logger.Debug("Found obsolete cached models."); forceRebuild = true; } + + // else cachedHash matches currentHash, we can try to load an existing dll } else { - _logger.Logger.Debug("Could not find cached models."); + _logger.Debug("Could not find cached models."); forceRebuild = true; } } Assembly assembly; - if (forceRebuild == false) + if (!forceRebuild) { // try to load the dll directly (avoid rebuilding) + // + // ensure that the .dll file does not have a corresponding .dll.delete file + // as that would mean the the .dll file is going to be deleted and should not + // be re-used - that should not happen in theory, but better be safe + // + // ensure that the .dll file is in the current codegen directory - when IIS + // or Express does a full restart, it can switch to an entirely new codegen + // directory, and then we end up referencing a dll which is *not* in that + // directory, and BuildManager fails to instantiate views ("the view found + // at ... was not created"). + // if (File.Exists(dllPathFile)) { var dllPath = File.ReadAllText(dllPathFile); - if (File.Exists(dllPath)) + var codegen = HttpRuntime.CodegenDir; + + _logger.Debug($"Cached models dll at {dllPath}."); + + if (File.Exists(dllPath) && !File.Exists(dllPath + ".delete") && dllPath.StartsWith(codegen)) { assembly = Assembly.LoadFile(dllPath); var attr = assembly.GetCustomAttribute(); @@ -359,13 +397,23 @@ namespace Umbraco.ModelsBuilder.Umbraco // with the "same but different" version of the assembly in memory _skipver = assembly.GetName().Version.Revision; - _logger.Logger.Debug("Loading cached models (dll)."); + _logger.Debug("Loading cached models (dll)."); return assembly; } + + _logger.Debug("Cached models dll cannot be loaded (invalid assembly)."); } + else if (!File.Exists(dllPath)) + _logger.Debug("Cached models dll does not exist."); + else if (File.Exists(dllPath + ".delete")) + _logger.Debug("Cached models dll is marked for deletion."); + else if (!dllPath.StartsWith(codegen)) + _logger.Debug("Cached models dll is in a different codegen directory."); + else + _logger.Debug("Cached models dll cannot be loaded (why?)."); } - // mmust reset the version in the file else it would keep growing + // must reset the version in the file else it would keep growing // loading cached modules only happens when the app restarts var text = File.ReadAllText(projFile); var match = AssemblyVersionRegex.Match(text); @@ -381,47 +429,80 @@ namespace Umbraco.ModelsBuilder.Umbraco //File.WriteAllText(Path.Combine(modelsDirectory, "models.dep"), "VER:" + _ver); _ver++; - assembly = BuildManager.GetCompiledAssembly(ProjVirt); - File.WriteAllText(dllPathFile, assembly.Location); + try + { + assembly = BuildManager.GetCompiledAssembly(ProjVirt); + File.WriteAllText(dllPathFile, assembly.Location); + } + catch + { + ClearOnFailingToCompile(dllPathFile, modelsHashFile, projFile); + throw; + } - _logger.Logger.Debug("Loading cached models (source)."); + _logger.Debug("Loading cached models (source)."); return assembly; } // need to rebuild - _logger.Logger.Debug("Rebuilding models."); + _logger.Debug("Rebuilding models."); // generate code, save - var code = GenerateModelsCode(ourFiles, typeModels); + var code = GenerateModelsCode(typeModels); // add extra attributes, // PureLiveAssembly helps identifying Assemblies that contain PureLive models // AssemblyVersion is so that we have a different version for each rebuild var ver = _ver == _skipver ? ++_ver : _ver; _ver++; - code = code.Replace("//ASSATTR", $@"[assembly: PureLiveAssembly] -[assembly:ModelsBuilderAssembly(PureLive = true, SourceHash = ""{currentHash}"")] + code = code.Replace("//ASSATTR", $@"[assembly:ModelsBuilderAssembly(PureLive = true, SourceHash = ""{currentHash}"")] [assembly:System.Reflection.AssemblyVersion(""0.0.0.{ver}"")]"); File.WriteAllText(modelsSrcFile, code); // generate proj, save - ourFiles["models.generated.cs"] = code; - var proj = GenerateModelsProj(ourFiles); + var projFiles = new Dictionary + { + { "models.generated.cs", code } + }; + var proj = GenerateModelsProj(projFiles); File.WriteAllText(projFile, proj); // compile and register - assembly = BuildManager.GetCompiledAssembly(ProjVirt); - File.WriteAllText(dllPathFile, assembly.Location); + try + { + assembly = BuildManager.GetCompiledAssembly(ProjVirt); + File.WriteAllText(dllPathFile, assembly.Location); + File.WriteAllText(modelsHashFile, currentHash); + } + catch + { + ClearOnFailingToCompile(dllPathFile, modelsHashFile, projFile); + throw; + } - // assuming we can write and it's not going to cause exceptions... - File.WriteAllText(modelsHashFile, currentHash); - - _logger.Logger.Debug("Done rebuilding."); + _logger.Debug("Done rebuilding."); return assembly; } + private void ClearOnFailingToCompile(string dllPathFile, string modelsHashFile, string projFile) + { + _logger.Debug("Failed to compile."); + + // the dll file reference still points to the previous dll, which is obsolete + // now and will be deleted by ASP.NET eventually, so better clear that reference. + // also touch the proj file to force views to recompile - don't delete as it's + // useful to have the source around for debugging. + try + { + if (File.Exists(dllPathFile)) File.Delete(dllPathFile); + if (File.Exists(modelsHashFile)) File.Delete(modelsHashFile); + if (File.Exists(projFile)) File.SetLastWriteTime(projFile, DateTime.Now); + } + catch { /* enough */ } + } + private static Infos RegisterModels(IEnumerable types) { - var ctorArgTypes = new[] { typeof (IPublishedElement) }; + var ctorArgTypes = new[] { typeof(IPublishedElement) }; var modelInfos = new Dictionary(StringComparer.InvariantCultureIgnoreCase); var map = new Dictionary(); @@ -433,7 +514,7 @@ namespace Umbraco.ModelsBuilder.Umbraco foreach (var ctor in type.GetConstructors()) { var parms = ctor.GetParameters(); - if (parms.Length == 1 && typeof (IPublishedElement).IsAssignableFrom(parms[0].ParameterType)) + if (parms.Length == 1 && typeof(IPublishedElement).IsAssignableFrom(parms[0].ParameterType)) { if (constructor != null) throw new InvalidOperationException($"Type {type.FullName} has more than one public constructor with one argument of type, or implementing, IPropertySet."); @@ -448,16 +529,17 @@ namespace Umbraco.ModelsBuilder.Umbraco var attribute = type.GetCustomAttribute(false); var typeName = attribute == null ? type.Name : attribute.ContentTypeAlias; - if (modelInfos.TryGetValue(typeName, out ModelInfo modelInfo)) + if (modelInfos.TryGetValue(typeName, out var modelInfo)) throw new InvalidOperationException($"Both types {type.FullName} and {modelInfo.ModelType.FullName} want to be a model type for content type with alias \"{typeName}\"."); // fixme use Core's ReflectionUtilities.EmitCtor !! - var meth = new DynamicMethod(string.Empty, typeof (IPublishedElement), ctorArgTypes, type.Module, true); + // Yes .. DynamicMethod is uber slow + var meth = new DynamicMethod(string.Empty, typeof(IPublishedElement), ctorArgTypes, type.Module, true); var gen = meth.GetILGenerator(); gen.Emit(OpCodes.Ldarg_0); gen.Emit(OpCodes.Newobj, constructor); gen.Emit(OpCodes.Ret); - var func = (Func) meth.CreateDelegate(typeof (Func)); + var func = (Func)meth.CreateDelegate(typeof(Func)); modelInfos[typeName] = new ModelInfo { ParameterType = parameterType, Ctor = func, ModelType = type }; map[typeName] = type; @@ -466,26 +548,16 @@ namespace Umbraco.ModelsBuilder.Umbraco return new Infos { ModelInfos = modelInfos.Count > 0 ? modelInfos : null, ModelTypeMap = map }; } - private static string GenerateModelsCode(IDictionary ourFiles, IList typeModels) + private string GenerateModelsCode(IList typeModels) { - var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; + var modelsDirectory = _config.ModelsDirectory; if (!Directory.Exists(modelsDirectory)) Directory.CreateDirectory(modelsDirectory); foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs")) File.Delete(file); - var map = typeModels.ToDictionary(x => x.Alias, x => x.ClrName); - foreach (var typeModel in typeModels) - { - foreach (var propertyModel in typeModel.Properties) - { - propertyModel.ClrTypeName = ModelType.MapToName(propertyModel.ModelClrType, map); - } - } - - var parseResult = new CodeParser().ParseWithReferencedAssemblies(ourFiles); - var builder = new TextBuilder(typeModels, parseResult, UmbracoConfig.For.ModelsBuilder().ModelsNamespace); + var builder = new TextBuilder(_config, typeModels); var codeBuilder = new StringBuilder(); builder.Generate(codeBuilder, builder.GetModelsToGenerate()); @@ -577,7 +649,7 @@ namespace Umbraco.ModelsBuilder.Umbraco //if (_building && OurFiles.Contains(changed)) //{ - // //_logger.Logger.Info("Ignoring files self-changes."); + // //_logger.Info("Ignoring files self-changes."); // return; //} @@ -585,9 +657,10 @@ namespace Umbraco.ModelsBuilder.Umbraco if (OurFiles.Contains(changed)) return; - _logger.Logger.Info("Detected files changes."); + _logger.Info("Detected files changes."); - ResetModels(); + lock (SyncRoot) // don't reset while being locked + ResetModels(); } public void Stop(bool immediate) diff --git a/src/Umbraco.ModelsBuilder/ReferencedAssemblies.cs b/src/Umbraco.ModelsBuilder.Embedded/ReferencedAssemblies.cs similarity index 54% rename from src/Umbraco.ModelsBuilder/ReferencedAssemblies.cs rename to src/Umbraco.ModelsBuilder.Embedded/ReferencedAssemblies.cs index 42e8b3b9c9..8886afa1c8 100644 --- a/src/Umbraco.ModelsBuilder/ReferencedAssemblies.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/ReferencedAssemblies.cs @@ -4,25 +4,19 @@ using System.Linq; using System.Reflection; using System.Web.Compilation; using System.Web.Hosting; -using Microsoft.CodeAnalysis; using Umbraco.Core; -namespace Umbraco.ModelsBuilder +namespace Umbraco.ModelsBuilder.Embedded { internal static class ReferencedAssemblies { private static readonly Lazy> LazyLocations; - private static readonly Lazy> LazyReferences; static ReferencedAssemblies() { LazyLocations = new Lazy>(() => HostingEnvironment.IsHosted ? GetAllReferencedAssembliesLocationFromBuildManager() : GetAllReferencedAssembliesFromDomain()); - - LazyReferences = new Lazy>(() => Locations - .Select(x => MetadataReference.CreateFromFile(x)) - .ToArray()); } /// @@ -31,19 +25,68 @@ namespace Umbraco.ModelsBuilder /// public static IEnumerable Locations => LazyLocations.Value; - /// - /// Gets the metadata reference of all the referenced assemblies. - /// - public static IEnumerable References => LazyReferences.Value; + public static Assembly GetNetStandardAssembly(List assemblies) + { + if (assemblies == null) + assemblies = BuildManager.GetReferencedAssemblies().Cast().ToList(); - // hosted, get referenced assemblies from the BuildManader and filter + // for some reason, netstandard is also missing from BuildManager.ReferencedAssemblies and yet, is part of + // the references that CSharpCompiler (above) receives - where is it coming from? cannot figure it out + try + { + // so, resorting to an ugly trick + // we should have System.Reflection.Metadata around, and it should reference netstandard + var someAssembly = assemblies.First(x => x.FullName.StartsWith("System.Reflection.Metadata,")); + var netStandardAssemblyName = someAssembly.GetReferencedAssemblies().First(x => x.FullName.StartsWith("netstandard,")); + var netStandard = Assembly.Load(netStandardAssemblyName.FullName); + return netStandard; + } + catch { /* never mind */ } + + return null; + } + + public static Assembly GetNetStandardAssembly() + { + // in PreApplicationStartMethod we cannot get BuildManager.Referenced assemblies, do it differently + try + { + var someAssembly = Assembly.Load("System.Reflection.Metadata"); + var netStandardAssemblyName = someAssembly.GetReferencedAssemblies().First(x => x.FullName.StartsWith("netstandard,")); + var netStandard = Assembly.Load(netStandardAssemblyName.FullName); + return netStandard; + } + catch { /* never mind */ } + + return null; + } + + // hosted, get referenced assemblies from the BuildManager and filter private static IEnumerable GetAllReferencedAssembliesLocationFromBuildManager() { - return BuildManager.GetReferencedAssemblies() - .Cast() + var assemblies = BuildManager.GetReferencedAssemblies().Cast().ToList(); + + assemblies.Add(typeof(ReferencedAssemblies).Assembly); // always include ourselves + + // see https://github.com/aspnet/RoslynCodeDomProvider/blob/master/src/Microsoft.CodeDom.Providers.DotNetCompilerPlatform/CSharpCompiler.cs: + // mentions "Bug 913691: Explicitly add System.Runtime as a reference." + // and explicitly adds System.Runtime to references before invoking csc.exe + // so, doing the same here + try + { + var systemRuntime = Assembly.Load("System.Runtime, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"); + assemblies.Add(systemRuntime); + } + catch { /* never mind */ } + + // for some reason, netstandard is also missing from BuildManager.ReferencedAssemblies and yet, is part of + // the references that CSharpCompiler (above) receives - where is it coming from? cannot figure it out + var netStandard = GetNetStandardAssembly(assemblies); + if (netStandard != null) assemblies.Add(netStandard); + + return assemblies .Where(x => !x.IsDynamic && !x.Location.IsNullOrWhiteSpace()) .Select(x => x.Location) - .And(typeof(ReferencedAssemblies).Assembly.Location) // always include ourselves .Distinct() .ToList(); } @@ -99,26 +142,6 @@ namespace Umbraco.ModelsBuilder // ---- - private static IEnumerable GetDeepReferencedAssemblies(Assembly assembly) - { - var visiting = new Stack(); - var visited = new HashSet(); - - visiting.Push(assembly); - visited.Add(assembly); - while (visiting.Count > 0) - { - var visAsm = visiting.Pop(); - foreach (var refAsm in visAsm.GetReferencedAssemblies() - .Select(TryLoad) - .Where(x => x != null && visited.Contains(x) == false)) - { - yield return refAsm; - visiting.Push(refAsm); - visited.Add(refAsm); - } - } - } private static Assembly TryLoad(AssemblyName name) { @@ -132,6 +155,5 @@ namespace Umbraco.ModelsBuilder return null; } } - } } diff --git a/src/Umbraco.ModelsBuilder/TypeExtensions.cs b/src/Umbraco.ModelsBuilder.Embedded/TypeExtensions.cs similarity index 96% rename from src/Umbraco.ModelsBuilder/TypeExtensions.cs rename to src/Umbraco.ModelsBuilder.Embedded/TypeExtensions.cs index d3b3ff6b4e..1f270a80a6 100644 --- a/src/Umbraco.ModelsBuilder/TypeExtensions.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/TypeExtensions.cs @@ -1,6 +1,6 @@ using System; -namespace Umbraco.ModelsBuilder +namespace Umbraco.ModelsBuilder.Embedded { internal static class TypeExtensions { diff --git a/src/Umbraco.ModelsBuilder/Umbraco.ModelsBuilder.csproj b/src/Umbraco.ModelsBuilder.Embedded/Umbraco.ModelsBuilder.Embedded.csproj similarity index 57% rename from src/Umbraco.ModelsBuilder/Umbraco.ModelsBuilder.csproj rename to src/Umbraco.ModelsBuilder.Embedded/Umbraco.ModelsBuilder.Embedded.csproj index 60ef944a8c..75121a635d 100644 --- a/src/Umbraco.ModelsBuilder/Umbraco.ModelsBuilder.csproj +++ b/src/Umbraco.ModelsBuilder.Embedded/Umbraco.ModelsBuilder.Embedded.csproj @@ -4,13 +4,15 @@ Debug AnyCPU - {7020A059-C0D1-43A0-8EFD-23591A0C9AF6} + {52AC0BA8-A60E-4E36-897B-E8B97A54ED1C} Library Properties - Umbraco.ModelsBuilder - Umbraco.ModelsBuilder + Umbraco.ModelsBuilder.Embedded + Umbraco.ModelsBuilder.Embedded v4.7.2 512 + true + 7.3 true @@ -28,13 +30,14 @@ TRACE prompt 4 - bin\Release\Umbraco.ModelsBuilder.xml + bin\Release\Umbraco.ModelsBuilder.Embedded.xml + + - @@ -46,62 +49,48 @@ Properties\SolutionInfo.cs - - - - - - - - + + + + - - - - - - - + + + + - - - - - - + - - - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - 2.8.0 + 2.10.0 1.0.0-beta2-19324-01 @@ -111,7 +100,7 @@ - {31785bc3-256c-4613-b2f5-a1b0bdded8c1} + {31785BC3-256C-4613-B2F5-A1B0BDDED8C1} Umbraco.Core @@ -119,5 +108,11 @@ Umbraco.Web + + + 5.2.7 + + + \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Umbraco/UmbracoServices.cs b/src/Umbraco.ModelsBuilder.Embedded/UmbracoServices.cs similarity index 68% rename from src/Umbraco.ModelsBuilder/Umbraco/UmbracoServices.cs rename to src/Umbraco.ModelsBuilder.Embedded/UmbracoServices.cs index f0347d9194..5ede5f45e9 100644 --- a/src/Umbraco.ModelsBuilder/Umbraco/UmbracoServices.cs +++ b/src/Umbraco.ModelsBuilder.Embedded/UmbracoServices.cs @@ -2,17 +2,16 @@ using System.Collections.Generic; using System.Linq; using Umbraco.Core; -using Umbraco.Core.Configuration; +using Umbraco.Core.Exceptions; using Umbraco.Core.Models; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.Services; using Umbraco.Core.Strings; -using Umbraco.ModelsBuilder.Building; -using Umbraco.ModelsBuilder.Configuration; +using Umbraco.ModelsBuilder.Embedded.Building; -namespace Umbraco.ModelsBuilder.Umbraco +namespace Umbraco.ModelsBuilder.Embedded { - public class UmbracoServices + public sealed class UmbracoServices { private readonly IContentTypeService _contentTypeService; private readonly IMediaTypeService _mediaTypeService; @@ -33,6 +32,10 @@ namespace Umbraco.ModelsBuilder.Umbraco { var types = new List(); + // TODO: this will require 3 rather large SQL queries on startup in PureLive. I know that these will be cached after lookup but it will slow + // down startup time ... BUT these queries are also used in NuCache on startup so we can't really avoid them. Maybe one day we can + // load all of these in in one query and still have them cached per service, and/or somehow improve the perf of these since they are used on startup + // in more than one place. types.AddRange(GetTypes(PublishedItemType.Content, _contentTypeService.GetAll().Cast().ToArray())); types.AddRange(GetTypes(PublishedItemType.Media, _mediaTypeService.GetAll().Cast().ToArray())); types.AddRange(GetTypes(PublishedItemType.Member, _memberTypeService.GetAll().Cast().ToArray())); @@ -60,38 +63,8 @@ namespace Umbraco.ModelsBuilder.Umbraco public static string GetClrName(string name, string alias) { - // ideally we should just be able to re-use Umbraco's alias, - // just upper-casing the first letter, however in v7 for backward - // compatibility reasons aliases derive from names via ToSafeAlias which is - // PreFilter = ApplyUrlReplaceCharacters, - // IsTerm = (c, leading) => leading - // ? char.IsLetter(c) // only letters - // : (char.IsLetterOrDigit(c) || c == '_'), // letter, digit or underscore - // StringType = CleanStringType.Ascii | CleanStringType.UmbracoCase, - // BreakTermsOnUpper = false - // - // but that is not ideal with acronyms and casing - // however we CANNOT change Umbraco - // so, adding a way to "do it right" deriving from name, here - - switch (UmbracoConfig.For.ModelsBuilder().ClrNameSource) - { - case ClrNameSource.RawAlias: - // use Umbraco's alias - return alias; - - case ClrNameSource.Alias: - // ModelsBuilder's legacy - but not ideal - return alias.ToCleanString(CleanStringType.ConvertCase | CleanStringType.PascalCase); - - case ClrNameSource.Name: - // derive from name - var source = name.TrimStart('_'); // because CleanStringType.ConvertCase accepts them - return source.ToCleanString(CleanStringType.ConvertCase | CleanStringType.PascalCase | CleanStringType.Ascii); - - default: - throw new Exception("Invalid ClrNameSource."); - } + // ModelsBuilder's legacy - but not ideal + return alias.ToCleanString(CleanStringType.ConvertCase | CleanStringType.PascalCase); } private IList GetTypes(PublishedItemType itemType, IContentTypeComposition[] contentTypes) @@ -116,36 +89,26 @@ namespace Umbraco.ModelsBuilder.Umbraco // of course this should never happen, but when it happens, better detect it // else we end up with weird nullrefs everywhere if (uniqueTypes.Contains(typeModel.ClrName)) - throw new Exception($"Panic: duplicate type ClrName \"{typeModel.ClrName}\"."); + throw new PanicException($"Panic: duplicate type ClrName \"{typeModel.ClrName}\"."); uniqueTypes.Add(typeModel.ClrName); - // fixme - we need a better way at figuring out what's an element type! - // and then we should not do the alias filtering below - bool IsElement(PublishedContentType x) - { - return x.Alias.InvariantEndsWith("Element"); - } - var publishedContentType = _publishedContentTypeFactory.CreateContentType(contentType); switch (itemType) { case PublishedItemType.Content: - if (IsElement(publishedContentType)) - { - typeModel.ItemType = TypeModel.ItemTypes.Element; - if (typeModel.ClrName.InvariantEndsWith("Element")) - typeModel.ClrName = typeModel.ClrName.Substring(0, typeModel.ClrName.Length - "Element".Length); - } - else - { - typeModel.ItemType = TypeModel.ItemTypes.Content; - } + typeModel.ItemType = publishedContentType.ItemType == PublishedItemType.Element + ? TypeModel.ItemTypes.Element + : TypeModel.ItemTypes.Content; break; case PublishedItemType.Media: - typeModel.ItemType = TypeModel.ItemTypes.Media; + typeModel.ItemType = publishedContentType.ItemType == PublishedItemType.Element + ? TypeModel.ItemTypes.Element + : TypeModel.ItemTypes.Media; break; case PublishedItemType.Member: - typeModel.ItemType = TypeModel.ItemTypes.Member; + typeModel.ItemType = publishedContentType.ItemType == PublishedItemType.Element + ? TypeModel.ItemTypes.Element + : TypeModel.ItemTypes.Member; break; default: throw new InvalidOperationException(string.Format("Unsupported PublishedItemType \"{0}\".", itemType)); @@ -166,7 +129,7 @@ namespace Umbraco.ModelsBuilder.Umbraco var publishedPropertyType = publishedContentType.GetPropertyType(propertyType.Alias); if (publishedPropertyType == null) - throw new Exception($"Panic: could not get published property type {contentType.Alias}.{propertyType.Alias}."); + throw new PanicException($"Panic: could not get published property type {contentType.Alias}.{propertyType.Alias}."); propertyModel.ModelClrType = publishedPropertyType.ModelClrType; @@ -188,7 +151,7 @@ namespace Umbraco.ModelsBuilder.Umbraco foreach (var contentType in contentTypes) { var typeModel = typeModels.SingleOrDefault(x => x.Id == contentType.Id); - if (typeModel == null) throw new Exception("Panic: no type model matching content type."); + if (typeModel == null) throw new PanicException("Panic: no type model matching content type."); IEnumerable compositionTypes; var contentTypeAsMedia = contentType as IMediaType; @@ -197,12 +160,12 @@ namespace Umbraco.ModelsBuilder.Umbraco if (contentTypeAsMedia != null) compositionTypes = contentTypeAsMedia.ContentTypeComposition; else if (contentTypeAsContent != null) compositionTypes = contentTypeAsContent.ContentTypeComposition; else if (contentTypeAsMember != null) compositionTypes = contentTypeAsMember.ContentTypeComposition; - else throw new Exception(string.Format("Panic: unsupported type \"{0}\".", contentType.GetType().FullName)); + else throw new PanicException(string.Format("Panic: unsupported type \"{0}\".", contentType.GetType().FullName)); foreach (var compositionType in compositionTypes) { var compositionModel = typeModels.SingleOrDefault(x => x.Id == compositionType.Id); - if (compositionModel == null) throw new Exception("Panic: composition type does not exist."); + if (compositionModel == null) throw new PanicException("Panic: composition type does not exist."); if (compositionType.Id == contentType.ParentId) continue; @@ -223,11 +186,9 @@ namespace Umbraco.ModelsBuilder.Umbraco { var groups = typeModels.GroupBy(x => x.Alias.ToLowerInvariant()); foreach (var group in groups.Where(x => x.Count() > 1)) - { throw new NotSupportedException($"Alias \"{group.Key}\" is used by types" + $" {string.Join(", ", group.Select(x => x.ItemType + ":\"" + x.Alias + "\""))}. Aliases have to be unique." + " One of the aliases must be modified in order to use the ModelsBuilder."); - } return typeModels; } diff --git a/src/Umbraco.ModelsBuilder/Api/ApiBasicAuthFilter.cs b/src/Umbraco.ModelsBuilder/Api/ApiBasicAuthFilter.cs deleted file mode 100644 index cc862ff207..0000000000 --- a/src/Umbraco.ModelsBuilder/Api/ApiBasicAuthFilter.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Web.Http.Controllers; -using System.Web.Security; -using Umbraco.Core; -using Umbraco.Core.Composing; -using Umbraco.Core.Configuration; -using Umbraco.Core.Models.Membership; - -namespace Umbraco.ModelsBuilder.Api -{ - - //TODO: This needs to be changed: - // * Authentication cannot happen in a filter, only Authorization - // * The filter must be an AuthorizationFilter, not an ActionFilter - // * Authorization must be done using the Umbraco logic - it is very specific for claim checking for ASP.Net Identity - // * Theoretically this shouldn't be required whatsoever because when we authenticate a request that has Basic Auth (i.e. for - // VS to work, it will add the correct Claims to the Identity and it will automatically be authorized. - // - // we *do* have POC supporting ASP.NET identity, however they require some config on the server - // we'll keep using this quick-and-dirty method for the time being - - public class ApiBasicAuthFilter : System.Web.Http.Filters.ActionFilterAttribute // use the http one, not mvc, with api controllers! - { - private static readonly char[] Separator = ":".ToCharArray(); - private readonly string _section; - - public ApiBasicAuthFilter(string section) - { - _section = section; - } - - public override void OnActionExecuting(HttpActionContext actionContext) - { - try - { - var user = Authenticate(actionContext.Request); - if (user == null || !user.AllowedSections.Contains(_section)) - { - actionContext.Response = new HttpResponseMessage(HttpStatusCode.Unauthorized); - } - //else - //{ - // // note - would that be a proper way to pass data to the controller? - // // see http://stevescodingblog.co.uk/basic-authentication-with-asp-net-webapi/ - // actionContext.ControllerContext.RouteData.Values["umbraco-user"] = user; - //} - - base.OnActionExecuting(actionContext); - } - catch - { - actionContext.Response = new HttpResponseMessage(HttpStatusCode.Unauthorized); - } - } - - private static IUser Authenticate(HttpRequestMessage request) - { - var ah = request.Headers.Authorization; - if (ah == null || ah.Scheme != "Basic") - return null; - - var token = ah.Parameter; - var credentials = Encoding.ASCII - .GetString(Convert.FromBase64String(token)) - .Split(Separator); - if (credentials.Length != 2) - return null; - - var username = ApiClient.DecodeTokenElement(credentials[0]); - var password = ApiClient.DecodeTokenElement(credentials[1]); - - var providerKey = UmbracoConfig.For.UmbracoSettings().Providers.DefaultBackOfficeUserProvider; - var provider = Membership.Providers[providerKey]; - if (provider == null || !provider.ValidateUser(username, password)) - return null; - var user = Current.Services.UserService.GetByUsername(username); - if (!user.IsApproved || user.IsLockedOut) - return null; - return user; - } - } -} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Api/ApiClient.cs b/src/Umbraco.ModelsBuilder/Api/ApiClient.cs deleted file mode 100644 index dde3641b97..0000000000 --- a/src/Umbraco.ModelsBuilder/Api/ApiClient.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Net.Http.Formatting; -using System.Net.Http.Headers; -using System.Text; - -namespace Umbraco.ModelsBuilder.Api -{ - public class ApiClient - { - private readonly string _url; - private readonly string _user; - private readonly string _password; - - private readonly JsonMediaTypeFormatter _formatter; - private readonly MediaTypeFormatter[] _formatters; - - // fixme hardcoded? - // could be options - but we cannot "discover" them as the API client runs outside of the web app - // in addition, anything that references the controller forces API clients to reference Umbraco.Core - private const string ApiControllerUrl = "/Umbraco/BackOffice/ModelsBuilder/ModelsBuilderApi/"; - - public ApiClient(string url, string user, string password) - { - _url = url.TrimEnd('/'); - _user = user; - _password = password; - - _formatter = new JsonMediaTypeFormatter(); - _formatters = new MediaTypeFormatter[] { _formatter }; - } - - private void SetBaseAddress(HttpClient client, string url) - { - try - { - client.BaseAddress = new Uri(url); - } - catch - { - throw new UriFormatException($"Invalid URI: the format of the URI \"{url}\" could not be determined."); - } - } - - public void ValidateClientVersion() - { - // FIXME - add proxys support - - var hch = new HttpClientHandler(); - - using (var client = new HttpClient(hch)) - { - SetBaseAddress(client, _url); - Authorize(client); - - var data = new ValidateClientVersionData - { - ClientVersion = ApiVersion.Current.Version, - MinServerVersionSupportingClient = ApiVersion.Current.MinServerVersionSupportingClient, - }; - - var result = client.PostAsync(_url + ApiControllerUrl + nameof(ModelsBuilderApiController.ValidateClientVersion), - data, _formatter).Result; - - // this is not providing enough details in case of an error - do our own reporting - //result.EnsureSuccessStatusCode(); - EnsureSuccess(result); - } - } - - public IDictionary GetModels(Dictionary ourFiles, string modelsNamespace) - { - // FIXME - add proxys support - - var hch = new HttpClientHandler(); - - //hch.Proxy = new WebProxy("path.to.proxy", 8888); - //hch.UseProxy = true; - - using (var client = new HttpClient(hch)) - { - SetBaseAddress(client, _url); - Authorize(client); - - var data = new GetModelsData - { - Namespace = modelsNamespace, - ClientVersion = ApiVersion.Current.Version, - MinServerVersionSupportingClient = ApiVersion.Current.MinServerVersionSupportingClient, - Files = ourFiles - }; - - var result = client.PostAsync(_url + ApiControllerUrl + nameof(ModelsBuilderApiController.GetModels), - data, _formatter).Result; - - // this is not providing enough details in case of an error - do our own reporting - //result.EnsureSuccessStatusCode(); - EnsureSuccess(result); - - var genFiles = result.Content.ReadAsAsync>(_formatters).Result; - return genFiles; - } - } - - private static void EnsureSuccess(HttpResponseMessage result) - { - if (result.IsSuccessStatusCode) return; - - var text = result.Content.ReadAsStringAsync().Result; - throw new Exception($"Response status code does not indicate success ({result.StatusCode})\n{text}"); - } - - private void Authorize(HttpClient client) - { - AuthorizeBasic(client); - } - - // fixme - for the time being, we don't cache the token and we auth on each API call - // not used at the moment - /* - private void AuthorizeIdentity(HttpClient client) - { - var formData = new FormUrlEncodedContent(new[] - { - new KeyValuePair("grant_type", "password"), - new KeyValuePair("userName", _user), - new KeyValuePair("password", _password), - }); - - var result = client.PostAsync(_url + UmbracoOAuthTokenUrl, formData).Result; - - EnsureSuccess(result); - - var token = result.Content.ReadAsAsync(_formatters).Result; - if (token.TokenType != "bearer") - throw new Exception($"Received invalid token type \"{token.TokenType}\", expected \"bearer\"."); - - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); - } - */ - - private void AuthorizeBasic(HttpClient client) - { - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes(EncodeTokenElement(_user) + ':' + EncodeTokenElement(_password)))); - } - - public static string EncodeTokenElement(string s) - { - return s.Replace("%", "%a").Replace(":", "%b"); - } - - public static string DecodeTokenElement(string s) - { - return s.Replace("%b", ":").Replace("%a", "%"); - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Api/ApiHelper.cs b/src/Umbraco.ModelsBuilder/Api/ApiHelper.cs deleted file mode 100644 index fa6492fe3f..0000000000 --- a/src/Umbraco.ModelsBuilder/Api/ApiHelper.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.Generic; -using System.Text; -using Umbraco.ModelsBuilder.Building; -using Umbraco.ModelsBuilder.Umbraco; - -namespace Umbraco.ModelsBuilder.Api -{ - internal static class ApiHelper - { - public static Dictionary GetModels(UmbracoServices umbracoServices, string modelsNamespace, IDictionary files) - { - var typeModels = umbracoServices.GetAllTypes(); - - var parseResult = new CodeParser().ParseWithReferencedAssemblies(files); - var builder = new TextBuilder(typeModels, parseResult, modelsNamespace); - - var models = new Dictionary(); - foreach (var typeModel in builder.GetModelsToGenerate()) - { - var sb = new StringBuilder(); - builder.Generate(sb, typeModel); - models[typeModel.ClrName] = sb.ToString(); - } - return models; - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Api/ApiVersion.cs b/src/Umbraco.ModelsBuilder/Api/ApiVersion.cs deleted file mode 100644 index 2ee64b8c54..0000000000 --- a/src/Umbraco.ModelsBuilder/Api/ApiVersion.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Reflection; - -namespace Umbraco.ModelsBuilder.Api -{ - /// - /// Manages API version handshake between client and server. - /// - public class ApiVersion - { - #region Configure - - // indicate the minimum version of the client API that is supported by this server's API. - // eg our Version = 4.8 but we support connections from VSIX down to version 3.2 - // => as a server, we accept connections from client down to version ... - private static readonly Version MinClientVersionSupportedByServerConst = new Version(3, 0, 0, 0); - - // indicate the minimum version of the server that can support the client API - // eg our Version = 4.8 and we know we're compatible with website server down to version 3.2 - // => as a client, we tell the server down to version ... that it should accept us - private static readonly Version MinServerVersionSupportingClientConst = new Version(3, 0, 0, 0); - - #endregion - - /// - /// Initializes a new instance of the class. - /// - /// The currently executing version. - /// The min client version supported by the server. - /// An opt min server version supporting the client. - internal ApiVersion(Version executingVersion, Version minClientVersionSupportedByServer, Version minServerVersionSupportingClient = null) - { - if (executingVersion == null) throw new ArgumentNullException(nameof(executingVersion)); - if (minClientVersionSupportedByServer == null) throw new ArgumentNullException(nameof(minClientVersionSupportedByServer)); - - Version = executingVersion; - MinClientVersionSupportedByServer = minClientVersionSupportedByServer; - MinServerVersionSupportingClient = minServerVersionSupportingClient; - } - - /// - /// Gets the currently executing API version. - /// - public static ApiVersion Current { get; } - = new ApiVersion(Assembly.GetExecutingAssembly().GetName().Version, - MinClientVersionSupportedByServerConst, MinServerVersionSupportingClientConst); - - /// - /// Gets the executing version of the API. - /// - public Version Version { get; } - - /// - /// Gets the min client version supported by the server. - /// - public Version MinClientVersionSupportedByServer { get; } - - /// - /// Gets the min server version supporting the client. - /// - public Version MinServerVersionSupportingClient { get; } - - /// - /// Gets a value indicating whether the API server is compatible with a client. - /// - /// The client version. - /// An opt min server version supporting the client. - /// - /// A client is compatible with a server if the client version is greater-or-equal _minClientVersionSupportedByServer - /// (ie client can be older than server, up to a point) AND the client version is lower-or-equal the server version - /// (ie client cannot be more recent than server) UNLESS the server . - /// - public bool IsCompatibleWith(Version clientVersion, Version minServerVersionSupportingClient = null) - { - // client cannot be older than server's min supported version - if (clientVersion < MinClientVersionSupportedByServer) - return false; - - // if we know about this client (client is older than server), it is supported - if (clientVersion <= Version) // if we know about this client (client older than server) - return true; - - // if we don't know about this client (client is newer than server), - // give server a chance to tell client it is, indeed, ok to support it - return minServerVersionSupportingClient != null && minServerVersionSupportingClient <= Version; - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Api/GetModelsData.cs b/src/Umbraco.ModelsBuilder/Api/GetModelsData.cs deleted file mode 100644 index 9a5c55afc2..0000000000 --- a/src/Umbraco.ModelsBuilder/Api/GetModelsData.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace Umbraco.ModelsBuilder.Api -{ - [DataContract] - public class GetModelsData : ValidateClientVersionData - { - [DataMember] - public string Namespace { get; set; } - - [DataMember] - public IDictionary Files { get; set; } - - public override bool IsValid => base.IsValid && Files != null; - } -} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Api/ModelsBuilderApiController.cs b/src/Umbraco.ModelsBuilder/Api/ModelsBuilderApiController.cs deleted file mode 100644 index 444910b069..0000000000 --- a/src/Umbraco.ModelsBuilder/Api/ModelsBuilderApiController.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using Umbraco.Core; -using Umbraco.Core.Configuration; -using Umbraco.ModelsBuilder.Configuration; -using Umbraco.ModelsBuilder.Umbraco; -using Umbraco.Web.Mvc; -using Umbraco.Web.WebApi; - -namespace Umbraco.ModelsBuilder.Api -{ - // read http://umbraco.com/follow-us/blog-archive/2014/1/17/heads-up,-breaking-change-coming-in-702-and-62.aspx - // read http://our.umbraco.org/forum/developers/api-questions/43025-Web-API-authentication - // UmbracoAuthorizedApiController :: /Umbraco/BackOffice/Zbu/ModelsBuilderApi/GetTypeModels - // UmbracoApiController :: /Umbraco/Zbu/ModelsBuilderApi/GetTypeModels ?? UNLESS marked with isbackoffice - // - // BEWARE! the controller url is hard-coded in ModelsBuilderApi and needs to be in sync! - - [PluginController(ControllerArea)] - [IsBackOffice] - //[UmbracoApplicationAuthorize(Constants.Applications.Developer)] // see ApiBasicAuthFilter - that one would be for ASP.NET identity - public class ModelsBuilderApiController : UmbracoApiController // UmbracoAuthorizedApiController - for ASP.NET identity - { - public const string ControllerArea = "ModelsBuilder"; - - private readonly UmbracoServices _umbracoServices; - - public ModelsBuilderApiController(UmbracoServices umbracoServices) - { - _umbracoServices = umbracoServices; - } - - // invoked by the API - [System.Web.Http.HttpPost] // use the http one, not mvc, with api controllers! - [ApiBasicAuthFilter("developer")] // have to use our own, non-cookie-based, auth - public HttpResponseMessage ValidateClientVersion(ValidateClientVersionData data) - { - if (!UmbracoConfig.For.ModelsBuilder().ApiServer) - return Request.CreateResponse(HttpStatusCode.Forbidden, "API server does not want to talk to you."); - - if (!ModelState.IsValid || data == null || !data.IsValid) - return Request.CreateResponse(HttpStatusCode.BadRequest, "Invalid data."); - - var checkResult = CheckVersion(data.ClientVersion, data.MinServerVersionSupportingClient); - return (checkResult.Success - ? Request.CreateResponse(HttpStatusCode.OK, "OK", Configuration.Formatters.JsonFormatter) - : checkResult.Result); - } - - // invoked by the API - [System.Web.Http.HttpPost] // use the http one, not mvc, with api controllers! - [ApiBasicAuthFilter("developer")] // have to use our own, non-cookie-based, auth - public HttpResponseMessage GetModels(GetModelsData data) - { - if (!UmbracoConfig.For.ModelsBuilder().ApiServer) - return Request.CreateResponse(HttpStatusCode.Forbidden, "API server does not want to talk to you."); - - if (!ModelState.IsValid || data == null || !data.IsValid) - return Request.CreateResponse(HttpStatusCode.BadRequest, "Invalid data."); - - var checkResult = CheckVersion(data.ClientVersion, data.MinServerVersionSupportingClient); - if (!checkResult.Success) - return checkResult.Result; - - var models = ApiHelper.GetModels(_umbracoServices, data.Namespace, data.Files); - - return Request.CreateResponse(HttpStatusCode.OK, models, Configuration.Formatters.JsonFormatter); - } - - private Attempt CheckVersion(Version clientVersion, Version minServerVersionSupportingClient) - { - if (clientVersion == null) - return Attempt.Fail(Request.CreateResponse(HttpStatusCode.Forbidden, - $"API version conflict: client version () is not compatible with server version({ApiVersion.Current.Version}).")); - - // minServerVersionSupportingClient can be null - var isOk = ApiVersion.Current.IsCompatibleWith(clientVersion, minServerVersionSupportingClient); - var response = isOk ? null : Request.CreateResponse(HttpStatusCode.Forbidden, - $"API version conflict: client version ({clientVersion}) is not compatible with server version({ApiVersion.Current.Version})."); - - return Attempt.If(isOk, response); - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Api/TokenData.cs b/src/Umbraco.ModelsBuilder/Api/TokenData.cs deleted file mode 100644 index c34a6c75c5..0000000000 --- a/src/Umbraco.ModelsBuilder/Api/TokenData.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Runtime.Serialization; - -namespace Umbraco.ModelsBuilder.Api -{ - [DataContract] - class TokenData - { - [DataMember(Name = "access_token")] - public string AccessToken { get; set; } - - [DataMember(Name = "token_type")] - public string TokenType { get; set; } - - [DataMember(Name = "expires_in")] - public int ExpiresIn { get; set; } - } -} diff --git a/src/Umbraco.ModelsBuilder/Api/ValidateClientVersionData.cs b/src/Umbraco.ModelsBuilder/Api/ValidateClientVersionData.cs deleted file mode 100644 index 39ef08d816..0000000000 --- a/src/Umbraco.ModelsBuilder/Api/ValidateClientVersionData.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Runtime.Serialization; - -namespace Umbraco.ModelsBuilder.Api -{ - [DataContract] - public class ValidateClientVersionData - { - // issues 32, 34... problems when serializing versions - // - // make sure System.Version objects are transfered as strings - // depending on the JSON serializer version, it looks like versions are causing issues - // see - // http://stackoverflow.com/questions/13170386/why-system-version-in-json-string-does-not-deserialize-correctly - // - // if the class is marked with [DataContract] then only properties marked with [DataMember] - // are serialized and the rest is ignored, see - // http://www.asp.net/web-api/overview/formats-and-model-binding/json-and-xml-serialization - - [DataMember] - public string ClientVersionString - { - get { return VersionToString(ClientVersion); } - set { ClientVersion = ParseVersion(value, false, "client"); } - } - - [DataMember] - public string MinServerVersionSupportingClientString - { - get { return VersionToString(MinServerVersionSupportingClient); } - set { MinServerVersionSupportingClient = ParseVersion(value, true, "minServer"); } - } - - // not serialized - public Version ClientVersion { get; set; } - public Version MinServerVersionSupportingClient { get; set; } - - private static string VersionToString(Version version) - { - return version?.ToString() ?? "0.0.0.0"; - } - - private static Version ParseVersion(string value, bool canBeNull, string name) - { - if (string.IsNullOrWhiteSpace(value) && canBeNull) - return null; - - Version version; - if (Version.TryParse(value, out version)) - return version; - - throw new ArgumentException($"Failed to parse \"{value}\" as {name} version."); - } - - public virtual bool IsValid => ClientVersion != null; - } -} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Building/CodeDomBuilder.cs b/src/Umbraco.ModelsBuilder/Building/CodeDomBuilder.cs deleted file mode 100644 index 925337bd1e..0000000000 --- a/src/Umbraco.ModelsBuilder/Building/CodeDomBuilder.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.CodeDom; -using System.Collections.Generic; - -namespace Umbraco.ModelsBuilder.Building -{ - // NOTE - // See nodes in Builder.cs class - that one does not work, is not complete, - // and was just some sort of experiment... - - /// - /// Implements a builder that works by using CodeDom - /// - internal class CodeDomBuilder : Builder - { - /// - /// Initializes a new instance of the class with a list of models to generate. - /// - /// The list of models to generate. - public CodeDomBuilder(IList typeModels) - : base(typeModels, null) - { } - - /// - /// Outputs a generated model to a code namespace. - /// - /// The code namespace. - /// The model to generate. - public void Generate(CodeNamespace ns, TypeModel typeModel) - { - // what about USING? - // what about references? - - if (typeModel.IsMixin) - { - var i = new CodeTypeDeclaration("I" + typeModel.ClrName) - { - IsInterface = true, - IsPartial = true, - Attributes = MemberAttributes.Public - }; - i.BaseTypes.Add(typeModel.BaseType == null ? "IPublishedContent" : "I" + typeModel.BaseType.ClrName); - - foreach (var mixinType in typeModel.DeclaringInterfaces) - i.BaseTypes.Add(mixinType.ClrName); - - i.Comments.Add(new CodeCommentStatement($"Mixin content Type {typeModel.Id} with alias \"{typeModel.Alias}\"")); - - foreach (var propertyModel in typeModel.Properties) - { - var p = new CodeMemberProperty - { - Name = propertyModel.ClrName, - Type = new CodeTypeReference(propertyModel.ModelClrType), - Attributes = MemberAttributes.Public, - HasGet = true, - HasSet = false - }; - i.Members.Add(p); - } - } - - var c = new CodeTypeDeclaration(typeModel.ClrName) - { - IsClass = true, - IsPartial = true, - Attributes = MemberAttributes.Public - }; - - c.BaseTypes.Add(typeModel.BaseType == null ? "PublishedContentModel" : typeModel.BaseType.ClrName); - - // if it's a missing it implements its own interface - if (typeModel.IsMixin) - c.BaseTypes.Add("I" + typeModel.ClrName); - - // write the mixins, if any, as interfaces - // only if not a mixin because otherwise the interface already has them - if (typeModel.IsMixin == false) - foreach (var mixinType in typeModel.DeclaringInterfaces) - c.BaseTypes.Add("I" + mixinType.ClrName); - - foreach (var mixin in typeModel.MixinTypes) - c.BaseTypes.Add("I" + mixin.ClrName); - - c.Comments.Add(new CodeCommentStatement($"Content Type {typeModel.Id} with alias \"{typeModel.Alias}\"")); - - foreach (var propertyModel in typeModel.Properties) - { - var p = new CodeMemberProperty - { - Name = propertyModel.ClrName, - Type = new CodeTypeReference(propertyModel.ModelClrType), - Attributes = MemberAttributes.Public, - HasGet = true, - HasSet = false - }; - p.GetStatements.Add(new CodeMethodReturnStatement( // return - new CodeMethodInvokeExpression( - new CodeMethodReferenceExpression( - new CodeThisReferenceExpression(), // this - "Value", // .Value - new[] // - { - new CodeTypeReference(propertyModel.ModelClrType) - }), - new CodeExpression[] // ("alias") - { - new CodePrimitiveExpression(propertyModel.Alias) - }))); - c.Members.Add(p); - } - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Building/CodeParser.cs b/src/Umbraco.ModelsBuilder/Building/CodeParser.cs deleted file mode 100644 index 30fcbf1f91..0000000000 --- a/src/Umbraco.ModelsBuilder/Building/CodeParser.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Umbraco.Core.Models.PublishedContent; - -namespace Umbraco.ModelsBuilder.Building -{ - /// - /// Implements code parsing. - /// - /// Parses user's code and look for generator's instructions. - internal class CodeParser - { - /// - /// Parses a set of file. - /// - /// A set of (filename,content) representing content to parse. - /// The result of the code parsing. - /// The set of files is a dictionary of name, content. - public ParseResult Parse(IDictionary files) - { - return Parse(files, Enumerable.Empty()); - } - - /// - /// Parses a set of file. - /// - /// A set of (filename,content) representing content to parse. - /// Assemblies to reference in compilations. - /// The result of the code parsing. - /// The set of files is a dictionary of name, content. - public ParseResult Parse(IDictionary files, IEnumerable references) - { - SyntaxTree[] trees; - var compiler = new Compiler { References = references }; - var compilation = compiler.GetCompilation("Umbraco.ModelsBuilder.Generated", files, out trees); - - var disco = new ParseResult(); - foreach (var tree in trees) - Parse(disco, compilation, tree); - - return disco; - } - - public ParseResult ParseWithReferencedAssemblies(IDictionary files) - { - return Parse(files, ReferencedAssemblies.References); - } - - private static void Parse(ParseResult disco, CSharpCompilation compilation, SyntaxTree tree) - { - var model = compilation.GetSemanticModel(tree); - - //we quite probably have errors but that is normal - //var diags = model.GetDiagnostics(); - - var classDecls = tree.GetRoot().DescendantNodes().OfType(); - foreach (var classSymbol in classDecls.Select(x => model.GetDeclaredSymbol(x))) - { - ParseClassSymbols(disco, classSymbol); - - var baseClassSymbol = classSymbol.BaseType; - if (baseClassSymbol != null) - //disco.SetContentBaseClass(SymbolDisplay.ToDisplayString(classSymbol), SymbolDisplay.ToDisplayString(baseClassSymbol)); - disco.SetContentBaseClass(classSymbol.Name, baseClassSymbol.Name); - - var interfaceSymbols = classSymbol.Interfaces; - disco.SetContentInterfaces(classSymbol.Name, //SymbolDisplay.ToDisplayString(classSymbol), - interfaceSymbols.Select(x => x.Name)); //SymbolDisplay.ToDisplayString(x))); - - var hasCtor = classSymbol.Constructors - .Any(x => - { - if (x.IsStatic) return false; - if (x.Parameters.Length != 1) return false; - var type1 = x.Parameters[0].Type; - var type2 = typeof (IPublishedContent); - return type1.ToDisplayString() == type2.FullName; - }); - - if (hasCtor) - disco.SetHasCtor(classSymbol.Name); - - foreach (var propertySymbol in classSymbol.GetMembers().Where(x => x is IPropertySymbol)) - ParsePropertySymbols(disco, classSymbol, propertySymbol); - - foreach (var staticMethodSymbol in classSymbol.GetMembers().Where(x => x is IMethodSymbol)) - ParseMethodSymbol(disco, classSymbol, staticMethodSymbol); - } - - var interfaceDecls = tree.GetRoot().DescendantNodes().OfType(); - foreach (var interfaceSymbol in interfaceDecls.Select(x => model.GetDeclaredSymbol(x))) - { - ParseClassSymbols(disco, interfaceSymbol); - - var interfaceSymbols = interfaceSymbol.Interfaces; - disco.SetContentInterfaces(interfaceSymbol.Name, //SymbolDisplay.ToDisplayString(interfaceSymbol), - interfaceSymbols.Select(x => x.Name)); // SymbolDisplay.ToDisplayString(x))); - } - - ParseAssemblySymbols(disco, compilation.Assembly); - } - - private static void ParseClassSymbols(ParseResult disco, ISymbol symbol) - { - foreach (var attrData in symbol.GetAttributes()) - { - var attrClassSymbol = attrData.AttributeClass; - - // handle errors - if (attrClassSymbol is IErrorTypeSymbol) continue; - if (attrData.AttributeConstructor == null) continue; - - var attrClassName = SymbolDisplay.ToDisplayString(attrClassSymbol); - switch (attrClassName) - { - case "Umbraco.ModelsBuilder.IgnorePropertyTypeAttribute": - var propertyAliasToIgnore = (string)attrData.ConstructorArguments[0].Value; - disco.SetIgnoredProperty(symbol.Name /*SymbolDisplay.ToDisplayString(symbol)*/, propertyAliasToIgnore); - break; - case "Umbraco.ModelsBuilder.RenamePropertyTypeAttribute": - var propertyAliasToRename = (string)attrData.ConstructorArguments[0].Value; - var propertyRenamed = (string)attrData.ConstructorArguments[1].Value; - disco.SetRenamedProperty(symbol.Name /*SymbolDisplay.ToDisplayString(symbol)*/, propertyAliasToRename, propertyRenamed); - break; - // that one causes all sorts of issues with references to Umbraco.Core in Roslyn - //case "Umbraco.Core.Models.PublishedContent.PublishedContentModelAttribute": - // var contentAliasToRename = (string)attrData.ConstructorArguments[0].Value; - // disco.SetRenamedContent(contentAliasToRename, symbol.Name /*SymbolDisplay.ToDisplayString(symbol)*/); - // break; - case "Umbraco.ModelsBuilder.ImplementContentTypeAttribute": - var contentAliasToRename = (string)attrData.ConstructorArguments[0].Value; - disco.SetRenamedContent(contentAliasToRename, symbol.Name, true /*SymbolDisplay.ToDisplayString(symbol)*/); - break; - } - } - } - - private static void ParsePropertySymbols(ParseResult disco, ISymbol classSymbol, ISymbol symbol) - { - foreach (var attrData in symbol.GetAttributes()) - { - var attrClassSymbol = attrData.AttributeClass; - - // handle errors - if (attrClassSymbol is IErrorTypeSymbol) continue; - if (attrData.AttributeConstructor == null) continue; - - var attrClassName = SymbolDisplay.ToDisplayString(attrClassSymbol); - // ReSharper disable once SwitchStatementMissingSomeCases - switch (attrClassName) - { - case "Umbraco.ModelsBuilder.ImplementPropertyTypeAttribute": - var propertyAliasToIgnore = (string)attrData.ConstructorArguments[0].Value; - disco.SetIgnoredProperty(classSymbol.Name /*SymbolDisplay.ToDisplayString(classSymbol)*/, propertyAliasToIgnore); - break; - } - } - } - - private static void ParseAssemblySymbols(ParseResult disco, ISymbol symbol) - { - foreach (var attrData in symbol.GetAttributes()) - { - var attrClassSymbol = attrData.AttributeClass; - - // handle errors - if (attrClassSymbol is IErrorTypeSymbol) continue; - if (attrData.AttributeConstructor == null) continue; - - var attrClassName = SymbolDisplay.ToDisplayString(attrClassSymbol); - switch (attrClassName) - { - case "Umbraco.ModelsBuilder.IgnoreContentTypeAttribute": - var contentAliasToIgnore = (string)attrData.ConstructorArguments[0].Value; - // see notes in IgnoreContentTypeAttribute - //var ignoreContent = (bool)attrData.ConstructorArguments[1].Value; - //var ignoreMixin = (bool)attrData.ConstructorArguments[1].Value; - //var ignoreMixinProperties = (bool)attrData.ConstructorArguments[1].Value; - disco.SetIgnoredContent(contentAliasToIgnore /*, ignoreContent, ignoreMixin, ignoreMixinProperties*/); - break; - - case "Umbraco.ModelsBuilder.RenameContentTypeAttribute": - var contentAliasToRename = (string) attrData.ConstructorArguments[0].Value; - var contentRenamed = (string)attrData.ConstructorArguments[1].Value; - disco.SetRenamedContent(contentAliasToRename, contentRenamed, false); - break; - - case "Umbraco.ModelsBuilder.ModelsBaseClassAttribute": - var modelsBaseClass = (INamedTypeSymbol) attrData.ConstructorArguments[0].Value; - if (modelsBaseClass is IErrorTypeSymbol) - throw new Exception($"Invalid base class type \"{modelsBaseClass.Name}\"."); - disco.SetModelsBaseClassName(SymbolDisplay.ToDisplayString(modelsBaseClass)); - break; - - case "Umbraco.ModelsBuilder.ModelsNamespaceAttribute": - var modelsNamespace= (string) attrData.ConstructorArguments[0].Value; - disco.SetModelsNamespace(modelsNamespace); - break; - - case "Umbraco.ModelsBuilder.ModelsUsingAttribute": - var usingNamespace = (string)attrData.ConstructorArguments[0].Value; - disco.SetUsingNamespace(usingNamespace); - break; - } - } - } - - private static void ParseMethodSymbol(ParseResult disco, ISymbol classSymbol, ISymbol symbol) - { - var methodSymbol = symbol as IMethodSymbol; - - if (methodSymbol == null - || !methodSymbol.IsStatic - || methodSymbol.IsGenericMethod - || methodSymbol.ReturnsVoid - || methodSymbol.IsExtensionMethod - || methodSymbol.Parameters.Length != 1) - return; - - var returnType = methodSymbol.ReturnType; - var paramSymbol = methodSymbol.Parameters[0]; - var paramType = paramSymbol.Type; - - // cannot do this because maybe the param type is ISomething and we don't have - // that type yet - will be generated - so cannot put any condition on it really - //const string iPublishedContent = "Umbraco.Core.Models.IPublishedContent"; - //var implements = paramType.AllInterfaces.Any(x => x.ToDisplayString() == iPublishedContent); - //if (!implements) - // return; - - disco.SetStaticMixinMethod(classSymbol.Name, methodSymbol.Name, returnType.Name, paramType.Name); - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Building/Compiler.cs b/src/Umbraco.ModelsBuilder/Building/Compiler.cs deleted file mode 100644 index 66064bef0b..0000000000 --- a/src/Umbraco.ModelsBuilder/Building/Compiler.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Web; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Umbraco.Core.Configuration; -using Umbraco.ModelsBuilder.Configuration; - -namespace Umbraco.ModelsBuilder.Building -{ - // main Roslyn compiler - internal class Compiler - { - private readonly LanguageVersion _languageVersion; - - public Compiler() - : this(UmbracoConfig.For.ModelsBuilder().LanguageVersion) - { } - - public Compiler(LanguageVersion languageVersion) - { - _languageVersion = languageVersion; - References = ReferencedAssemblies.References; - Debug = HttpContext.Current != null && HttpContext.Current.IsDebuggingEnabled; - } - - // gets or sets the references - public IEnumerable References { get; set; } - - public bool Debug { get; set; } - - // gets a compilation - public CSharpCompilation GetCompilation(string assemblyName, IDictionary files) - { - SyntaxTree[] trees; - return GetCompilation(assemblyName, files, out trees); - } - - // gets a compilation - // used by CodeParser to get a "compilation" of the existing files - public CSharpCompilation GetCompilation(string assemblyName, IDictionary files, out SyntaxTree[] trees) - { - var options = new CSharpParseOptions(_languageVersion); - trees = files.Select(x => - { - var text = x.Value; - var tree = CSharpSyntaxTree.ParseText(text, /*options:*/ options); - var diagnostic = tree.GetDiagnostics().FirstOrDefault(y => y.Severity == DiagnosticSeverity.Error); - if (diagnostic != null) - ThrowExceptionFromDiagnostic(x.Key, x.Value, diagnostic); - return tree; - }).ToArray(); - - var refs = References; - - var compilationOptions = new CSharpCompilationOptions( - OutputKind.DynamicallyLinkedLibrary, - assemblyIdentityComparer: DesktopAssemblyIdentityComparer.Default, - optimizationLevel: Debug ? OptimizationLevel.Debug : OptimizationLevel.Release - ); - var compilation = CSharpCompilation.Create( - assemblyName, - /*syntaxTrees:*/ trees, - /*references:*/ refs, - compilationOptions); - - return compilation; - } - - // compile files into a Dll - // used by ModelsBuilderBackOfficeController in [Live]Dll mode, to compile the models to disk - public void Compile(string assemblyName, IDictionary files, string binPath) - { - var assemblyPath = Path.Combine(binPath, assemblyName + ".dll"); - using (var stream = new FileStream(assemblyPath, FileMode.Create)) - { - Compile(assemblyName, files, stream); - } - - // this is how we'd create the pdb: - /* - var pdbPath = Path.Combine(binPath, assemblyName + ".pdb"); - - // create the compilation - var compilation = GetCompilation(assemblyName, files); - - // check diagnostics for errors (not warnings) - foreach (var diag in compilation.GetDiagnostics().Where(x => x.Severity == DiagnosticSeverity.Error)) - ThrowExceptionFromDiagnostic(files, diag); - - // emit - var result = compilation.Emit(assemblyPath, pdbPath); - if (result.Success) return; - - // deal with errors - var diagnostic = result.Diagnostics.First(x => x.Severity == DiagnosticSeverity.Error); - ThrowExceptionFromDiagnostic(files, diagnostic); - */ - } - - // compile files into an assembly - public Assembly Compile(string assemblyName, IDictionary files) - { - using (var stream = new MemoryStream()) - { - Compile(assemblyName, files, stream); - return Assembly.Load(stream.GetBuffer()); - } - } - - // compile one file into an assembly - public Assembly Compile(string assemblyName, string path, string code) - { - using (var stream = new MemoryStream()) - { - Compile(assemblyName, new Dictionary { { path, code } }, stream); - return Assembly.Load(stream.GetBuffer()); - } - } - - // compiles files into a stream - public void Compile(string assemblyName, IDictionary files, Stream stream) - { - // create the compilation - var compilation = GetCompilation(assemblyName, files); - - // check diagnostics for errors (not warnings) - foreach (var diag in compilation.GetDiagnostics().Where(x => x.Severity == DiagnosticSeverity.Error)) - ThrowExceptionFromDiagnostic(files, diag); - - // emit - var result = compilation.Emit(stream); - if (result.Success) return; - - // deal with errors - var diagnostic = result.Diagnostics.First(x => x.Severity == DiagnosticSeverity.Error); - ThrowExceptionFromDiagnostic(files, diagnostic); - } - - // compiles one file into a stream - public void Compile(string assemblyName, string path, string code, Stream stream) - { - Compile(assemblyName, new Dictionary { { path, code } }, stream); - } - - private static void ThrowExceptionFromDiagnostic(IDictionary files, Diagnostic diagnostic) - { - var message = diagnostic.GetMessage(); - if (diagnostic.Location == Location.None) - throw new CompilerException(message); - - var position = diagnostic.Location.GetLineSpan().StartLinePosition.Line + 1; - var path = diagnostic.Location.SourceTree.FilePath; - var code = files.ContainsKey(path) ? files[path] : string.Empty; - throw new CompilerException(message, path, code, position); - } - - private static void ThrowExceptionFromDiagnostic(string path, string code, Diagnostic diagnostic) - { - var message = diagnostic.GetMessage(); - if (diagnostic.Location == Location.None) - throw new CompilerException(message); - - var position = diagnostic.Location.GetLineSpan().StartLinePosition.Line + 1; - throw new CompilerException(message, path, code, position); - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Building/CompilerException.cs b/src/Umbraco.ModelsBuilder/Building/CompilerException.cs deleted file mode 100644 index e978f67ae5..0000000000 --- a/src/Umbraco.ModelsBuilder/Building/CompilerException.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; - -namespace Umbraco.ModelsBuilder.Building -{ - public class CompilerException : Exception - { - public CompilerException(string message) - : base(message) - { } - - public CompilerException(string message, string path, string sourceCode, int line) - : base(message) - { - Path = path; - SourceCode = sourceCode; - Line = line; - } - - public string Path { get; } = string.Empty; - - public string SourceCode { get; } = string.Empty; - - public int Line { get; } = -1; - } -} diff --git a/src/Umbraco.ModelsBuilder/Building/ParseResult.cs b/src/Umbraco.ModelsBuilder/Building/ParseResult.cs deleted file mode 100644 index d1f61363ff..0000000000 --- a/src/Umbraco.ModelsBuilder/Building/ParseResult.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Umbraco.ModelsBuilder.Building -{ - /// - /// Contains the result of a code parsing. - /// - internal class ParseResult - { - // "alias" is the umbraco alias - // content "name" is the complete name eg Foo.Bar.Name - // property "name" is just the local name - - // see notes in IgnoreContentTypeAttribute - - private readonly HashSet _ignoredContent - = new HashSet(StringComparer.InvariantCultureIgnoreCase); - //private readonly HashSet _ignoredMixin - // = new HashSet(StringComparer.InvariantCultureIgnoreCase); - //private readonly HashSet _ignoredMixinProperties - // = new HashSet(StringComparer.InvariantCultureIgnoreCase); - private readonly Dictionary _renamedContent - = new Dictionary(StringComparer.InvariantCultureIgnoreCase); - private readonly HashSet _withImplementContent - = new HashSet(StringComparer.InvariantCultureIgnoreCase); - private readonly Dictionary> _ignoredProperty - = new Dictionary>(); - private readonly Dictionary> _renamedProperty - = new Dictionary>(); - private readonly Dictionary _contentBase - = new Dictionary(); - private readonly Dictionary _contentInterfaces - = new Dictionary(); - private readonly List _usingNamespaces - = new List(); - private readonly Dictionary> _staticMixins - = new Dictionary>(); - private readonly HashSet _withCtor - = new HashSet(StringComparer.InvariantCultureIgnoreCase); - - public static readonly ParseResult Empty = new ParseResult(); - - private class StaticMixinMethodInfo - { - public StaticMixinMethodInfo(string contentName, string methodName, string returnType, string paramType) - { - ContentName = contentName; - MethodName = methodName; - //ReturnType = returnType; - //ParamType = paramType; - } - - // short name eg Type1 - public string ContentName { get; private set; } - - // short name eg GetProp1 - public string MethodName { get; private set; } - - // those types cannot be FQ because when parsing, some of them - // might not exist since we're generating them... and so prob. - // that info is worthless - not using it anyway at the moment... - - //public string ReturnType { get; private set; } - //public string ParamType { get; private set; } - } - - #region Declare - - // content with that alias should not be generated - // alias can end with a * (wildcard) - public void SetIgnoredContent(string contentAlias /*, bool ignoreContent, bool ignoreMixin, bool ignoreMixinProperties*/) - { - //if (ignoreContent) - _ignoredContent.Add(contentAlias); - //if (ignoreMixin) - // _ignoredMixin.Add(contentAlias); - //if (ignoreMixinProperties) - // _ignoredMixinProperties.Add(contentAlias); - } - - // content with that alias should be generated with a different name - public void SetRenamedContent(string contentAlias, string contentName, bool withImplement) - { - _renamedContent[contentAlias] = contentName; - if (withImplement) - _withImplementContent.Add(contentAlias); - } - - // property with that alias should not be generated - // applies to content name and any content that implements it - // here, contentName may be an interface - // alias can end with a * (wildcard) - public void SetIgnoredProperty(string contentName, string propertyAlias) - { - HashSet ignores; - if (!_ignoredProperty.TryGetValue(contentName, out ignores)) - ignores = _ignoredProperty[contentName] = new HashSet(StringComparer.InvariantCultureIgnoreCase); - ignores.Add(propertyAlias); - } - - // property with that alias should be generated with a different name - // applies to content name and any content that implements it - // here, contentName may be an interface - public void SetRenamedProperty(string contentName, string propertyAlias, string propertyName) - { - Dictionary renames; - if (!_renamedProperty.TryGetValue(contentName, out renames)) - renames = _renamedProperty[contentName] = new Dictionary(StringComparer.InvariantCultureIgnoreCase); - renames[propertyAlias] = propertyName; - } - - // content with that name has a base class so no need to generate one - public void SetContentBaseClass(string contentName, string baseName) - { - if (baseName.ToLowerInvariant() != "object") - _contentBase[contentName] = baseName; - } - - // content with that name implements the interfaces - public void SetContentInterfaces(string contentName, IEnumerable interfaceNames) - { - _contentInterfaces[contentName] = interfaceNames.ToArray(); - } - - public void SetModelsBaseClassName(string modelsBaseClassName) - { - ModelsBaseClassName = modelsBaseClassName; - } - - public void SetModelsNamespace(string modelsNamespace) - { - ModelsNamespace = modelsNamespace; - } - - public void SetUsingNamespace(string usingNamespace) - { - _usingNamespaces.Add(usingNamespace); - } - - public void SetStaticMixinMethod(string contentName, string methodName, string returnType, string paramType) - { - if (!_staticMixins.ContainsKey(contentName)) - _staticMixins[contentName] = new List(); - - _staticMixins[contentName].Add(new StaticMixinMethodInfo(contentName, methodName, returnType, paramType)); - } - - public void SetHasCtor(string contentName) - { - _withCtor.Add(contentName); - } - - #endregion - - #region Query - - public bool IsIgnored(string contentAlias) - { - return IsContentOrMixinIgnored(contentAlias, _ignoredContent); - } - - //public bool IsMixinIgnored(string contentAlias) - //{ - // return IsContentOrMixinIgnored(contentAlias, _ignoredMixin); - //} - - //public bool IsMixinPropertiesIgnored(string contentAlias) - //{ - // return IsContentOrMixinIgnored(contentAlias, _ignoredMixinProperties); - //} - - private static bool IsContentOrMixinIgnored(string contentAlias, HashSet ignored) - { - if (ignored.Contains(contentAlias)) return true; - return ignored - .Where(x => x.EndsWith("*")) - .Select(x => x.Substring(0, x.Length - 1)) - .Any(x => contentAlias.StartsWith(x, StringComparison.InvariantCultureIgnoreCase)); - } - - public bool HasContentBase(string contentName) - { - return _contentBase.ContainsKey(contentName); - } - - public bool IsContentRenamed(string contentAlias) - { - return _renamedContent.ContainsKey(contentAlias); - } - - public bool HasContentImplement(string contentAlias) - { - return _withImplementContent.Contains(contentAlias); - } - - public string ContentClrName(string contentAlias) - { - string name; - return (_renamedContent.TryGetValue(contentAlias, out name)) ? name : null; - } - - public bool IsPropertyIgnored(string contentName, string propertyAlias) - { - HashSet ignores; - if (_ignoredProperty.TryGetValue(contentName, out ignores)) - { - if (ignores.Contains(propertyAlias)) return true; - if (ignores - .Where(x => x.EndsWith("*")) - .Select(x => x.Substring(0, x.Length - 1)) - .Any(x => propertyAlias.StartsWith(x, StringComparison.InvariantCultureIgnoreCase))) - return true; - } - string baseName; - if (_contentBase.TryGetValue(contentName, out baseName) - && IsPropertyIgnored(baseName, propertyAlias)) return true; - string[] interfaceNames; - if (_contentInterfaces.TryGetValue(contentName, out interfaceNames) - && interfaceNames.Any(interfaceName => IsPropertyIgnored(interfaceName, propertyAlias))) return true; - return false; - } - - public string PropertyClrName(string contentName, string propertyAlias) - { - Dictionary renames; - string name; - if (_renamedProperty.TryGetValue(contentName, out renames) - && renames.TryGetValue(propertyAlias, out name)) return name; - string baseName; - if (_contentBase.TryGetValue(contentName, out baseName) - && null != (name = PropertyClrName(baseName, propertyAlias))) return name; - string[] interfaceNames; - if (_contentInterfaces.TryGetValue(contentName, out interfaceNames) - && null != (name = interfaceNames - .Select(interfaceName => PropertyClrName(interfaceName, propertyAlias)) - .FirstOrDefault(x => x != null))) return name; - return null; - } - - public bool HasModelsBaseClassName - { - get { return !string.IsNullOrWhiteSpace(ModelsBaseClassName); } - } - - public string ModelsBaseClassName { get; private set; } - - public bool HasModelsNamespace - { - get { return !string.IsNullOrWhiteSpace(ModelsNamespace); } - } - - public string ModelsNamespace { get; private set; } - - public IEnumerable UsingNamespaces - { - get { return _usingNamespaces; } - } - - public IEnumerable StaticMixinMethods(string contentName) - { - return _staticMixins.ContainsKey(contentName) - ? _staticMixins[contentName].Select(x => x.MethodName) - : Enumerable.Empty() ; - } - - public bool HasCtor(string contentName) - { - return _withCtor.Contains(contentName); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Configuration/ClrNameSource.cs b/src/Umbraco.ModelsBuilder/Configuration/ClrNameSource.cs deleted file mode 100644 index d195846411..0000000000 --- a/src/Umbraco.ModelsBuilder/Configuration/ClrNameSource.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Umbraco.ModelsBuilder.Configuration -{ - /// - /// Defines the CLR name sources. - /// - public enum ClrNameSource - { - /// - /// No source. - /// - Nothing = 0, - - /// - /// Use the name as source. - /// - Name, - - /// - /// Use the alias as source. - /// - Alias, - - /// - /// Use the alias directly. - /// - RawAlias - } -} diff --git a/src/Umbraco.ModelsBuilder/Configuration/Config.cs b/src/Umbraco.ModelsBuilder/Configuration/Config.cs deleted file mode 100644 index ebfe4be709..0000000000 --- a/src/Umbraco.ModelsBuilder/Configuration/Config.cs +++ /dev/null @@ -1,357 +0,0 @@ -using System; -using System.Configuration; -using System.IO; -using System.Reflection; -using System.Web.Configuration; -using System.Web.Hosting; -using Microsoft.CodeAnalysis.CSharp; -using Umbraco.Core; - -namespace Umbraco.ModelsBuilder.Configuration -{ - /// - /// Represents the models builder configuration. - /// - public class Config - { - private static Config _value; - - /// - /// Gets the configuration - internal so that the UmbracoConfig extension - /// can get the value to initialize its own value. Either a value has - /// been provided via the Setup method, or a new instance is created, which - /// will load settings from the config file. - /// - internal static Config Value => _value ?? new Config(); - - /// - /// Sets the configuration programmatically. - /// - /// The configuration. - /// - /// Once the configuration has been accessed via the UmbracoConfig extension, - /// it cannot be changed anymore, and using this method will achieve nothing. - /// For tests, see UmbracoConfigExtensions.ResetConfig(). - /// - public static void Setup(Config config) - { - _value = config; - } - - internal const string DefaultStaticMixinGetterPattern = "Get{0}"; - internal const LanguageVersion DefaultLanguageVersion = LanguageVersion.CSharp6; - internal const string DefaultModelsNamespace = "Umbraco.Web.PublishedModels"; - internal const ClrNameSource DefaultClrNameSource = ClrNameSource.Alias; // for legacy reasons - internal const string DefaultModelsDirectory = "~/App_Data/Models"; - - /// - /// Initializes a new instance of the class. - /// - private Config() - { - const string prefix = "Umbraco.ModelsBuilder."; - - // giant kill switch, default: false - // must be explicitely set to true for anything else to happen - Enable = ConfigurationManager.AppSettings[prefix + "Enable"] == "true"; - - // ensure defaults are initialized for tests - StaticMixinGetterPattern = DefaultStaticMixinGetterPattern; - LanguageVersion = DefaultLanguageVersion; - ModelsNamespace = DefaultModelsNamespace; - ClrNameSource = DefaultClrNameSource; - ModelsDirectory = HostingEnvironment.IsHosted - ? HostingEnvironment.MapPath(DefaultModelsDirectory) - : DefaultModelsDirectory.TrimStart("~/"); - DebugLevel = 0; - - // stop here, everything is false - if (!Enable) return; - - // mode - var modelsMode = ConfigurationManager.AppSettings[prefix + "ModelsMode"]; - if (!string.IsNullOrWhiteSpace(modelsMode)) - { - switch (modelsMode) - { - case nameof(ModelsMode.Nothing): - ModelsMode = ModelsMode.Nothing; - break; - case nameof(ModelsMode.PureLive): - ModelsMode = ModelsMode.PureLive; - break; - case nameof(ModelsMode.Dll): - ModelsMode = ModelsMode.Dll; - break; - case nameof(ModelsMode.LiveDll): - ModelsMode = ModelsMode.LiveDll; - break; - case nameof(ModelsMode.AppData): - ModelsMode = ModelsMode.AppData; - break; - case nameof(ModelsMode.LiveAppData): - ModelsMode = ModelsMode.LiveAppData; - break; - default: - throw new ConfigurationErrorsException($"ModelsMode \"{modelsMode}\" is not a valid mode." - + " Note that modes are case-sensitive."); - } - } - - // default: false - EnableApi = ConfigurationManager.AppSettings[prefix + "EnableApi"].InvariantEquals("true"); - AcceptUnsafeModelsDirectory = ConfigurationManager.AppSettings[prefix + "AcceptUnsafeModelsDirectory"].InvariantEquals("true"); - - // default: true - EnableFactory = !ConfigurationManager.AppSettings[prefix + "EnableFactory"].InvariantEquals("false"); - StaticMixinGetters = !ConfigurationManager.AppSettings[prefix + "StaticMixinGetters"].InvariantEquals("false"); - FlagOutOfDateModels = !ConfigurationManager.AppSettings[prefix + "FlagOutOfDateModels"].InvariantEquals("false"); - - // default: initialized above with DefaultModelsNamespace const - var value = ConfigurationManager.AppSettings[prefix + "ModelsNamespace"]; - if (!string.IsNullOrWhiteSpace(value)) - ModelsNamespace = value; - - // default: initialized above with DefaultStaticMixinGetterPattern const - value = ConfigurationManager.AppSettings[prefix + "StaticMixinGetterPattern"]; - if (!string.IsNullOrWhiteSpace(value)) - StaticMixinGetterPattern = value; - - // default: initialized above with DefaultLanguageVersion const - value = ConfigurationManager.AppSettings[prefix + "LanguageVersion"]; - if (!string.IsNullOrWhiteSpace(value)) - { - LanguageVersion lv; - if (!Enum.TryParse(value, true, out lv)) - throw new ConfigurationErrorsException($"Invalid language version \"{value}\"."); - LanguageVersion = lv; - } - - // default: initialized above with DefaultClrNameSource const - value = ConfigurationManager.AppSettings[prefix + "ClrNameSource"]; - if (!string.IsNullOrWhiteSpace(value)) - { - switch (value) - { - case nameof(ClrNameSource.Nothing): - ClrNameSource = ClrNameSource.Nothing; - break; - case nameof(ClrNameSource.Alias): - ClrNameSource = ClrNameSource.Alias; - break; - case nameof(ClrNameSource.RawAlias): - ClrNameSource = ClrNameSource.RawAlias; - break; - case nameof(ClrNameSource.Name): - ClrNameSource = ClrNameSource.Name; - break; - default: - throw new ConfigurationErrorsException($"ClrNameSource \"{value}\" is not a valid source." - + " Note that sources are case-sensitive."); - } - } - - // default: initialized above with DefaultModelsDirectory const - value = ConfigurationManager.AppSettings[prefix + "ModelsDirectory"]; - if (!string.IsNullOrWhiteSpace(value)) - { - var root = HostingEnvironment.IsHosted - ? HostingEnvironment.MapPath("~/") - : Directory.GetCurrentDirectory(); - if (root == null) - throw new ConfigurationErrorsException("Could not determine root directory."); - - // GetModelsDirectory will ensure that the path is safe - ModelsDirectory = GetModelsDirectory(root, value, AcceptUnsafeModelsDirectory); - } - - // default: 0 - value = ConfigurationManager.AppSettings[prefix + "DebugLevel"]; - if (!string.IsNullOrWhiteSpace(value)) - { - int debugLevel; - if (!int.TryParse(value, out debugLevel)) - throw new ConfigurationErrorsException($"Invalid debug level \"{value}\"."); - DebugLevel = debugLevel; - } - - // not flagging if not generating, or live (incl. pure) - if (ModelsMode == ModelsMode.Nothing || ModelsMode.IsLive()) - FlagOutOfDateModels = false; - } - - /// - /// Initializes a new instance of the class. - /// - public Config( - bool enable = false, - ModelsMode modelsMode = ModelsMode.Nothing, - bool enableApi = true, - string modelsNamespace = null, - bool enableFactory = true, - LanguageVersion languageVersion = DefaultLanguageVersion, - bool staticMixinGetters = true, - string staticMixinGetterPattern = null, - bool flagOutOfDateModels = true, - ClrNameSource clrNameSource = DefaultClrNameSource, - string modelsDirectory = null, - bool acceptUnsafeModelsDirectory = false, - int debugLevel = 0) - { - Enable = enable; - ModelsMode = modelsMode; - - EnableApi = enableApi; - ModelsNamespace = string.IsNullOrWhiteSpace(modelsNamespace) ? DefaultModelsNamespace : modelsNamespace; - EnableFactory = enableFactory; - LanguageVersion = languageVersion; - StaticMixinGetters = staticMixinGetters; - StaticMixinGetterPattern = string.IsNullOrWhiteSpace(staticMixinGetterPattern) ? DefaultStaticMixinGetterPattern : staticMixinGetterPattern; - FlagOutOfDateModels = flagOutOfDateModels; - ClrNameSource = clrNameSource; - ModelsDirectory = string.IsNullOrWhiteSpace(modelsDirectory) ? DefaultModelsDirectory : modelsDirectory; - AcceptUnsafeModelsDirectory = acceptUnsafeModelsDirectory; - DebugLevel = debugLevel; - } - - // internal for tests - internal static string GetModelsDirectory(string root, string config, bool acceptUnsafe) - { - // making sure it is safe, ie under the website root, - // unless AcceptUnsafeModelsDirectory and then everything is OK. - - if (!Path.IsPathRooted(root)) - throw new ConfigurationErrorsException($"Root is not rooted \"{root}\"."); - - if (config.StartsWith("~/")) - { - var dir = Path.Combine(root, config.TrimStart("~/")); - - // sanitize - GetFullPath will take care of any relative - // segments in path, eg '../../foo.tmp' - it may throw a SecurityException - // if the combined path reaches illegal parts of the filesystem - dir = Path.GetFullPath(dir); - root = Path.GetFullPath(root); - - if (!dir.StartsWith(root) && !acceptUnsafe) - throw new ConfigurationErrorsException($"Invalid models directory \"{config}\"."); - - return dir; - } - - if (acceptUnsafe) - return Path.GetFullPath(config); - - throw new ConfigurationErrorsException($"Invalid models directory \"{config}\"."); - } - - /// - /// Gets a value indicating whether the whole models experience is enabled. - /// - /// - /// If this is false then absolutely nothing happens. - /// Default value is false which means that unless we have this setting, nothing happens. - /// - public bool Enable { get; } - - /// - /// Gets the models mode. - /// - public ModelsMode ModelsMode { get; } - - /// - /// Gets a value indicating whether to serve the API. - /// - public bool ApiServer => EnableApi && ApiInstalled && IsDebug; - - /// - /// Gets a value indicating whether to enable the API. - /// - /// - /// Default value is true. - /// The API is used by the Visual Studio extension and the console tool to talk to Umbraco - /// and retrieve the content types. It needs to be enabled so the extension & tool can work. - /// - public bool EnableApi { get; } - - /// - /// Gets a value indicating whether the API is installed. - /// - // fixme - this is now always true as the API is part of Core - public bool ApiInstalled => true; - - /// - /// Gets a value indicating whether system.web/compilation/@debug is true. - /// - public bool IsDebug - { - get - { - var section = (CompilationSection) ConfigurationManager.GetSection("system.web/compilation"); - return section != null && section.Debug; - } - } - - /// - /// Gets the models namespace. - /// - /// That value could be overriden by other (attribute in user's code...). Return default if no value was supplied. - public string ModelsNamespace { get; } - - /// - /// Gets a value indicating whether we should enable the models factory. - /// - /// Default value is true because no factory is enabled by default in Umbraco. - public bool EnableFactory { get; } - - /// - /// Gets the Roslyn parser language version. - /// - /// Default value is CSharp6. - public LanguageVersion LanguageVersion { get; } - - /// - /// Gets a value indicating whether to generate static mixin getters. - /// - /// Default value is false for backward compat reaons. - public bool StaticMixinGetters { get; } - - /// - /// Gets the string pattern for mixin properties static getter name. - /// - /// Default value is "GetXxx". Standard string format. - public string StaticMixinGetterPattern { get; } - - /// - /// Gets a value indicating whether we should flag out-of-date models. - /// - /// Models become out-of-date when data types or content types are updated. When this - /// setting is activated the ~/App_Data/Models/ood.txt file is then created. When models are - /// generated through the dashboard, the files is cleared. Default value is false. - public bool FlagOutOfDateModels { get; } - - /// - /// Gets the CLR name source. - /// - public ClrNameSource ClrNameSource { get; } - - /// - /// Gets the models directory. - /// - /// Default is ~/App_Data/Models but that can be changed. - public string ModelsDirectory { get; } - - /// - /// Gets a value indicating whether to accept an unsafe value for ModelsDirectory. - /// - /// An unsafe value is an absolute path, or a relative path pointing outside - /// of the website root. - public bool AcceptUnsafeModelsDirectory { get; } - - /// - /// Gets a value indicating the debug log level. - /// - /// 0 means minimal (safe on live site), anything else means more and more details (maybe not safe). - public int DebugLevel { get; } - } -} diff --git a/src/Umbraco.ModelsBuilder/Configuration/UmbracoConfigExtensions.cs b/src/Umbraco.ModelsBuilder/Configuration/UmbracoConfigExtensions.cs deleted file mode 100644 index acc587e779..0000000000 --- a/src/Umbraco.ModelsBuilder/Configuration/UmbracoConfigExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Threading; -using Umbraco.Core.Configuration; - -namespace Umbraco.ModelsBuilder.Configuration -{ - /// - /// Provides extension methods for the class. - /// - public static class UmbracoConfigExtensions - { - private static Config _config; - - /// - /// Gets the models builder configuration. - /// - /// The umbraco configuration. - /// The models builder configuration. - /// Getting the models builder configuration freezes its state, - /// and any attempt at modifying the configuration using the Setup method - /// will be ignored. - public static Config ModelsBuilder(this UmbracoConfig umbracoConfig) - { - // capture the current Config2.Default value, cannot change anymore - LazyInitializer.EnsureInitialized(ref _config, () => Config.Value); - return _config; - } - - // internal for tests - internal static void ResetConfig() - { - _config = null; - } - } -} \ No newline at end of file diff --git a/src/Umbraco.ModelsBuilder/Dashboard/BuilderDashboardHelper.cs b/src/Umbraco.ModelsBuilder/Dashboard/BuilderDashboardHelper.cs deleted file mode 100644 index 9e5741805e..0000000000 --- a/src/Umbraco.ModelsBuilder/Dashboard/BuilderDashboardHelper.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System; -using System.Text; -using Umbraco.Core.Configuration; -using Umbraco.ModelsBuilder.Configuration; -using Umbraco.ModelsBuilder.Umbraco; - -namespace Umbraco.ModelsBuilder.Dashboard -{ - internal static class BuilderDashboardHelper - { - public static bool CanGenerate() - { - return UmbracoConfig.For.ModelsBuilder().ModelsMode.SupportsExplicitGeneration(); - } - - public static bool GenerateCausesRestart() - { - return UmbracoConfig.For.ModelsBuilder().ModelsMode.IsAnyDll(); - } - - public static bool AreModelsOutOfDate() - { - return OutOfDateModelsStatus.IsOutOfDate; - } - - public static string LastError() - { - return ModelsGenerationError.GetLastError(); - } - - public static string Text() - { - var config = UmbracoConfig.For.ModelsBuilder(); - if (!config.Enable) - return "ModelsBuilder is disabled
(the .Enable key is missing, or its value is not 'true')."; - - var sb = new StringBuilder(); - - sb.Append("ModelsBuilder is enabled, with the following configuration:"); - - sb.Append("
    "); - - sb.Append("
  • The models factory is "); - sb.Append(config.EnableFactory || config.ModelsMode == ModelsMode.PureLive - ? "enabled" - : "not enabled. Umbraco will not use models"); - sb.Append(".
  • "); - - sb.Append("
  • The API is "); - if (config.ApiInstalled && config.EnableApi) - { - sb.Append("installed and enabled"); - if (!config.IsDebug) sb.Append(".
    However, the API runs only with debug compilation mode"); - } - else if (config.ApiInstalled || config.EnableApi) - sb.Append(config.ApiInstalled ? "installed but not enabled" : "enabled but not installed"); - else sb.Append("neither installed nor enabled"); - sb.Append(".
    "); - if (!config.ApiServer) - sb.Append("External tools such as Visual Studio cannot use the API"); - else - sb.Append("The API endpoint is open on this server"); - sb.Append(".
  • "); - - sb.Append(config.ModelsMode != ModelsMode.Nothing - ? $"
  • {config.ModelsMode} models are enabled.
  • " - : "
  • No models mode is specified: models will not be generated.
  • "); - - sb.Append($"
  • Models namespace is {config.ModelsNamespace}.
  • "); - - sb.Append("
  • Static mixin getters are "); - sb.Append(config.StaticMixinGetters ? "enabled" : "disabled"); - if (config.StaticMixinGetters) - { - sb.Append(". The pattern for getters is "); - sb.Append(string.IsNullOrWhiteSpace(config.StaticMixinGetterPattern) - ? "not configured (will use default)" - : $"\"{config.StaticMixinGetterPattern}\""); - } - sb.Append(".
  • "); - - sb.Append("
  • Tracking of out-of-date models is "); - sb.Append(config.FlagOutOfDateModels ? "enabled" : "not enabled"); - sb.Append(".
  • "); - - sb.Append("
"); - - return sb.ToString(); - } - } -} diff --git a/src/Umbraco.ModelsBuilder/EnumerableExtensions.cs b/src/Umbraco.ModelsBuilder/EnumerableExtensions.cs deleted file mode 100644 index da77bfa958..0000000000 --- a/src/Umbraco.ModelsBuilder/EnumerableExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Umbraco.ModelsBuilder -{ - public static class EnumerableExtensions - { - public static void RemoveAll(this IList list, Func predicate) - { - for (var i = 0; i < list.Count; i++) - { - if (predicate(list[i])) - { - list.RemoveAt(i--); // i-- is important here! - } - } - } - - public static IEnumerable And(this IEnumerable enumerable, T item) - { - foreach (var x in enumerable) yield return x; - yield return item; - } - - public static IEnumerable AndIfNotNull(this IEnumerable enumerable, T item) - where T : class - { - foreach (var x in enumerable) yield return x; - if (item != null) - yield return item; - } - } -} diff --git a/src/Umbraco.ModelsBuilder/IgnoreContentTypeAttribute.cs b/src/Umbraco.ModelsBuilder/IgnoreContentTypeAttribute.cs deleted file mode 100644 index e5ab3a2e35..0000000000 --- a/src/Umbraco.ModelsBuilder/IgnoreContentTypeAttribute.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Policy; -using System.Text; -using System.Threading.Tasks; -using Umbraco.ModelsBuilder; - -namespace Umbraco.ModelsBuilder -{ - // for the time being it's all-or-nothing - // when the content type is ignored then - // - no class is generated for that content type - // - no class is generated for any child of that class - // - no interface is generated for that content type as a mixin - // - and it is ignored as a mixin ie its properties are not generated - // in the future we may way to do - // [assembly:IgnoreContentType("foo", ContentTypeIgnorable.ContentType|ContentTypeIgnorable.Mixin|ContentTypeIgnorable.MixinProperties)] - // so that we can - // - generate a class for that content type, or not - // - if not generated, generate children or not - // - if generate children, include properties or not - // - generate an interface for that content type as a mixin - // - if not generated, still generate properties in content types implementing the mixin or not - // but... I'm not even sure it makes sense - // if we don't want it... we don't want it. - - // about ignoring - // - content (don't generate the content, use as mixin) - // - mixin (don't generate the interface, use the properties) - // - mixin properties (generate the interface, not the properties) - // - mixin: local only or children too... - - /// - /// Indicates that no model should be generated for a specified content type alias. - /// - /// When a content type is ignored, its descendants are also ignored. - /// Supports trailing wildcard eg "foo*". - [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true, Inherited = false)] - public sealed class IgnoreContentTypeAttribute : Attribute - { - public IgnoreContentTypeAttribute(string alias /*, bool ignoreContent = true, bool ignoreMixin = true, bool ignoreMixinProperties = true*/) - {} - } -} - diff --git a/src/Umbraco.ModelsBuilder/IgnorePropertyTypeAttribute.cs b/src/Umbraco.ModelsBuilder/IgnorePropertyTypeAttribute.cs deleted file mode 100644 index 4dce0f9b7f..0000000000 --- a/src/Umbraco.ModelsBuilder/IgnorePropertyTypeAttribute.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Umbraco.ModelsBuilder -{ - /// - /// Indicates that no model should be generated for a specified property type alias. - /// - /// Supports trailing wildcard eg "foo*". - [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] - public sealed class IgnorePropertyTypeAttribute : Attribute - { - public IgnorePropertyTypeAttribute(string alias) - {} - } -} diff --git a/src/Umbraco.ModelsBuilder/ImplementContentTypeAttribute.cs b/src/Umbraco.ModelsBuilder/ImplementContentTypeAttribute.cs deleted file mode 100644 index 142f115b07..0000000000 --- a/src/Umbraco.ModelsBuilder/ImplementContentTypeAttribute.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; - -namespace Umbraco.ModelsBuilder -{ - // NOTE - // that attribute should inherit from PublishedModelAttribute - // so we do not have different syntaxes - // but... is sealed at the moment. - - /// - /// Indicates that a (partial) class defines the model type for a specific alias. - /// - /// Though a model will be generated - so that is the way to register a rename. - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] - public sealed class ImplementContentTypeAttribute : Attribute - { - public ImplementContentTypeAttribute(string alias) - { } - } -} diff --git a/src/Umbraco.ModelsBuilder/ModelsBaseClassAttribute.cs b/src/Umbraco.ModelsBuilder/ModelsBaseClassAttribute.cs deleted file mode 100644 index 3c401b7fdb..0000000000 --- a/src/Umbraco.ModelsBuilder/ModelsBaseClassAttribute.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace Umbraco.ModelsBuilder -{ - /// - /// Indicates the default base class for models. - /// - /// Otherwise it is PublishedContentModel. Would make sense to inherit from PublishedContentModel. - [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] - public sealed class ModelsBaseClassAttribute : Attribute - { - public ModelsBaseClassAttribute(Type type) - {} - } -} - diff --git a/src/Umbraco.ModelsBuilder/ModelsNamespaceAttribute.cs b/src/Umbraco.ModelsBuilder/ModelsNamespaceAttribute.cs deleted file mode 100644 index 1b1d62d9bc..0000000000 --- a/src/Umbraco.ModelsBuilder/ModelsNamespaceAttribute.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace Umbraco.ModelsBuilder -{ - /// - /// Indicates the models namespace. - /// - /// Will override anything else that might come from settings. - [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] - public sealed class ModelsNamespaceAttribute : Attribute - { - public ModelsNamespaceAttribute(string modelsNamespace) - {} - } -} - diff --git a/src/Umbraco.ModelsBuilder/ModelsUsingAttribute.cs b/src/Umbraco.ModelsBuilder/ModelsUsingAttribute.cs deleted file mode 100644 index 8fe1335631..0000000000 --- a/src/Umbraco.ModelsBuilder/ModelsUsingAttribute.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Policy; -using System.Text; -using System.Threading.Tasks; -using Umbraco.ModelsBuilder; - -namespace Umbraco.ModelsBuilder -{ - /// - /// Indicates namespaces that should be used in generated models (in using clauses). - /// - [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true, Inherited = false)] - public sealed class ModelsUsingAttribute : Attribute - { - public ModelsUsingAttribute(string usingNamespace) - {} - } -} - diff --git a/src/Umbraco.ModelsBuilder/Properties/AssemblyInfo.cs b/src/Umbraco.ModelsBuilder/Properties/AssemblyInfo.cs deleted file mode 100644 index a2f8d1ae1e..0000000000 --- a/src/Umbraco.ModelsBuilder/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("Umbraco.ModelsBuilder")] -[assembly: AssemblyDescription("Umbraco ModelsBuilder")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyProduct("Umbraco CMS")] - -[assembly: ComVisible(false)] -[assembly: Guid("7020a059-c0d1-43a0-8efd-23591a0c9af6")] - -// code analysis -// IDE1006 is broken, wants _value syntax for consts, etc - and it's even confusing ppl at MS, kill it -[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "~_~")] diff --git a/src/Umbraco.ModelsBuilder/PublishedPropertyTypeExtensions.cs b/src/Umbraco.ModelsBuilder/PublishedPropertyTypeExtensions.cs deleted file mode 100644 index b67ba54432..0000000000 --- a/src/Umbraco.ModelsBuilder/PublishedPropertyTypeExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Umbraco.Core; -using Umbraco.Core.Models.PublishedContent; - -namespace Umbraco.ModelsBuilder -{ - public static class PublishedPropertyTypeExtensions - { - // fixme - need to rewrite that one - we don't have prevalues anymore - //public static KeyValuePair[] PreValues(this PublishedPropertyType propertyType) - //{ - // return ApplicationContext.Current.Services.DataTypeService - // .GetPreValuesCollectionByDataTypeId(propertyType.DataType.Id) - // .PreValuesAsArray - // .Select(x => new KeyValuePair(x.Id, x.Value)) - // .ToArray(); - //} - } -} diff --git a/src/Umbraco.ModelsBuilder/PureLiveAssemblyAttribute.cs b/src/Umbraco.ModelsBuilder/PureLiveAssemblyAttribute.cs deleted file mode 100644 index dfe369dc21..0000000000 --- a/src/Umbraco.ModelsBuilder/PureLiveAssemblyAttribute.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace Umbraco.ModelsBuilder -{ - /// - /// Indicates that an Assembly is a PureLive models assembly. - /// - /// Though technically not required, ie models will work without it, the attribute - /// can be used by Umbraco view models binder to figure out whether the model type comes - /// from a PureLive Assembly. - [Obsolete("Should use ModelsBuilderAssemblyAttribute but that requires a change in Umbraco Core.")] - [AttributeUsage(AttributeTargets.Assembly /*, AllowMultiple = false, Inherited = false*/)] - public sealed class PureLiveAssemblyAttribute : Attribute - { } -} diff --git a/src/Umbraco.ModelsBuilder/RenameContentTypeAttribute.cs b/src/Umbraco.ModelsBuilder/RenameContentTypeAttribute.cs deleted file mode 100644 index 0f985e70b3..0000000000 --- a/src/Umbraco.ModelsBuilder/RenameContentTypeAttribute.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Umbraco.ModelsBuilder -{ - /// - /// Indicates a model name for a specified content alias. - /// - [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true, Inherited = false)] - public sealed class RenameContentTypeAttribute : Attribute - { - public RenameContentTypeAttribute(string alias, string name) - {} - } -} diff --git a/src/Umbraco.ModelsBuilder/RenamePropertyTypeAttribute.cs b/src/Umbraco.ModelsBuilder/RenamePropertyTypeAttribute.cs deleted file mode 100644 index 0d8fd31b63..0000000000 --- a/src/Umbraco.ModelsBuilder/RenamePropertyTypeAttribute.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Umbraco.ModelsBuilder -{ - /// - /// Indicates a model name for a specified property alias. - /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] - public sealed class RenamePropertyTypeAttribute : Attribute - { - public RenamePropertyTypeAttribute(string alias, string name) - {} - } -} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/LiveModelsProvider.cs b/src/Umbraco.ModelsBuilder/Umbraco/LiveModelsProvider.cs deleted file mode 100644 index b6c37a3558..0000000000 --- a/src/Umbraco.ModelsBuilder/Umbraco/LiveModelsProvider.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Threading; -using System.Web; -using System.Web.Hosting; -using Umbraco.Core.Composing; -using Umbraco.Core.Configuration; -using Umbraco.Core.Logging; -using Umbraco.ModelsBuilder.Configuration; -using Umbraco.ModelsBuilder.Umbraco; -using Umbraco.Web.Cache; - -// will install only if configuration says it needs to be installed -[assembly: PreApplicationStartMethod(typeof(LiveModelsProviderModule), "Install")] - -namespace Umbraco.ModelsBuilder.Umbraco -{ - // supports LiveDll and LiveAppData - but not PureLive - public sealed class LiveModelsProvider - { - private static UmbracoServices _umbracoServices; - private static Mutex _mutex; - private static int _req; - - internal static bool IsEnabled - { - get - { - var config = UmbracoConfig.For.ModelsBuilder(); - return config.ModelsMode.IsLiveNotPure(); - // we do not manage pure live here - } - } - - internal static void Install(UmbracoServices umbracoServices) - { - // just be sure - if (!IsEnabled) - return; - - _umbracoServices = umbracoServices; - - // initialize mutex - // ApplicationId will look like "/LM/W3SVC/1/Root/AppName" - // name is system-wide and must be less than 260 chars - var name = HostingEnvironment.ApplicationID + "/UmbracoLiveModelsProvider"; - _mutex = new Mutex(false, name); - - // anything changes, and we want to re-generate models. - ContentTypeCacheRefresher.CacheUpdated += RequestModelsGeneration; - DataTypeCacheRefresher.CacheUpdated += RequestModelsGeneration; - - // at the end of a request since we're restarting the pool - // NOTE - this does NOT trigger - see module below - //umbracoApplication.EndRequest += GenerateModelsIfRequested; - } - - // NOTE - // Using HttpContext Items fails because CacheUpdated triggers within - // some asynchronous backend task where we seem to have no HttpContext. - - // So we use a static (non request-bound) var to register that models - // need to be generated. Could be by another request. Anyway. We could - // have collisions but... you know the risk. - - private static void RequestModelsGeneration(object sender, EventArgs args) - { - //HttpContext.Current.Items[this] = true; - Current.Logger.Debug("Requested to generate models."); - Interlocked.Exchange(ref _req, 1); - } - - public static void GenerateModelsIfRequested(object sender, EventArgs args) - { - //if (HttpContext.Current.Items[this] == null) return; - if (Interlocked.Exchange(ref _req, 0) == 0) return; - - // cannot use a simple lock here because we don't want another AppDomain - // to generate while we do... and there could be 2 AppDomains if the app restarts. - - try - { - Current.Logger.Debug("Generate models..."); - const int timeout = 2*60*1000; // 2 mins - _mutex.WaitOne(timeout); // wait until it is safe, and acquire - Current.Logger.Info("Generate models now."); - GenerateModels(); - ModelsGenerationError.Clear(); - Current.Logger.Info("Generated."); - } - catch (TimeoutException) - { - Current.Logger.Warn("Timeout, models were NOT generated."); - } - catch (Exception e) - { - ModelsGenerationError.Report("Failed to build Live models.", e); - Current.Logger.Error("Failed to generate models.", e); - } - finally - { - _mutex.ReleaseMutex(); // release - } - } - - private static void GenerateModels() - { - var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; - - var bin = HostingEnvironment.MapPath("~/bin"); - if (bin == null) - throw new Exception("Panic: bin is null."); - - var config = UmbracoConfig.For.ModelsBuilder(); - - // EnableDllModels will recycle the app domain - but this request will end properly - ModelsBuilderBackOfficeController.GenerateModels(_umbracoServices, modelsDirectory, config.ModelsMode.IsAnyDll() ? bin : null); - } - } - - // have to do this because it's the only way to subscribe to EndRequest, - // module is installed by assembly attribute at the top of this file - public class LiveModelsProviderModule : IHttpModule - { - public void Init(HttpApplication app) - { - app.EndRequest += LiveModelsProvider.GenerateModelsIfRequested; - } - - public void Dispose() - { - // nothing - } - - public static void Install() - { - if (!LiveModelsProvider.IsEnabled) - return; - - HttpApplication.RegisterModule(typeof(LiveModelsProviderModule)); - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderBackOfficeController.cs b/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderBackOfficeController.cs deleted file mode 100644 index 19c9bda5da..0000000000 --- a/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderBackOfficeController.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Runtime.Serialization; -using System.Text; -using System.Web.Hosting; -using Umbraco.Core.Configuration; -using Umbraco.ModelsBuilder.Building; -using Umbraco.ModelsBuilder.Configuration; -using Umbraco.ModelsBuilder.Dashboard; -using Umbraco.Web.Editors; -using Umbraco.Web.WebApi.Filters; - -namespace Umbraco.ModelsBuilder.Umbraco -{ - /// - /// API controller for use in the Umbraco back office with Angular resources - /// - /// - /// We've created a different controller for the backoffice/angular specifically this is to ensure that the - /// correct CSRF security is adhered to for angular and it also ensures that this controller is not subseptipal to - /// global WebApi formatters being changed since this is always forced to only return Angular JSON Specific formats. - /// - [UmbracoApplicationAuthorize(Core.Constants.Applications.Developer)] - public class ModelsBuilderBackOfficeController : UmbracoAuthorizedJsonController - { - private readonly UmbracoServices _umbracoServices; - - public ModelsBuilderBackOfficeController(UmbracoServices umbracoServices) - { - _umbracoServices = umbracoServices; - } - - // invoked by the dashboard - // requires that the user is logged into the backoffice and has access to the developer section - // beware! the name of the method appears in modelsbuilder.controller.js - [System.Web.Http.HttpPost] // use the http one, not mvc, with api controllers! - public HttpResponseMessage BuildModels() - { - try - { - if (!UmbracoConfig.For.ModelsBuilder().ModelsMode.SupportsExplicitGeneration()) - { - var result2 = new BuildResult { Success = false, Message = "Models generation is not enabled." }; - return Request.CreateResponse(HttpStatusCode.OK, result2, Configuration.Formatters.JsonFormatter); - } - - var modelsDirectory = UmbracoConfig.For.ModelsBuilder().ModelsDirectory; - - var bin = HostingEnvironment.MapPath("~/bin"); - if (bin == null) - throw new Exception("Panic: bin is null."); - - // EnableDllModels will recycle the app domain - but this request will end properly - GenerateModels(modelsDirectory, UmbracoConfig.For.ModelsBuilder().ModelsMode.IsAnyDll() ? bin : null); - - ModelsGenerationError.Clear(); - } - catch (Exception e) - { - ModelsGenerationError.Report("Failed to build models.", e); - } - - return Request.CreateResponse(HttpStatusCode.OK, GetDashboardResult(), Configuration.Formatters.JsonFormatter); - } - - // invoked by the back-office - // requires that the user is logged into the backoffice and has access to the developer section - [System.Web.Http.HttpGet] // use the http one, not mvc, with api controllers! - public HttpResponseMessage GetModelsOutOfDateStatus() - { - var status = OutOfDateModelsStatus.IsEnabled - ? (OutOfDateModelsStatus.IsOutOfDate - ? new OutOfDateStatus { Status = OutOfDateType.OutOfDate } - : new OutOfDateStatus { Status = OutOfDateType.Current }) - : new OutOfDateStatus { Status = OutOfDateType.Unknown }; - - return Request.CreateResponse(HttpStatusCode.OK, status, Configuration.Formatters.JsonFormatter); - } - - // invoked by the back-office - // requires that the user is logged into the backoffice and has access to the developer section - // beware! the name of the method appears in modelsbuilder.controller.js - [System.Web.Http.HttpGet] // use the http one, not mvc, with api controllers! - public HttpResponseMessage GetDashboard() - { - return Request.CreateResponse(HttpStatusCode.OK, GetDashboardResult(), Configuration.Formatters.JsonFormatter); - } - - private Dashboard GetDashboardResult() - { - return new Dashboard - { - Enable = UmbracoConfig.For.ModelsBuilder().Enable, - Text = BuilderDashboardHelper.Text(), - CanGenerate = BuilderDashboardHelper.CanGenerate(), - GenerateCausesRestart = BuilderDashboardHelper.GenerateCausesRestart(), - OutOfDateModels = BuilderDashboardHelper.AreModelsOutOfDate(), - LastError = BuilderDashboardHelper.LastError(), - }; - } - - private void GenerateModels(string modelsDirectory, string bin) - { - GenerateModels(_umbracoServices, modelsDirectory, bin); - } - - internal static void GenerateModels(UmbracoServices umbracoServices, string modelsDirectory, string bin) - { - if (!Directory.Exists(modelsDirectory)) - Directory.CreateDirectory(modelsDirectory); - - foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs")) - File.Delete(file); - - var typeModels = umbracoServices.GetAllTypes(); - - var ourFiles = Directory.GetFiles(modelsDirectory, "*.cs").ToDictionary(x => x, File.ReadAllText); - var parseResult = new CodeParser().ParseWithReferencedAssemblies(ourFiles); - var builder = new TextBuilder(typeModels, parseResult, UmbracoConfig.For.ModelsBuilder().ModelsNamespace); - - foreach (var typeModel in builder.GetModelsToGenerate()) - { - var sb = new StringBuilder(); - builder.Generate(sb, typeModel); - var filename = Path.Combine(modelsDirectory, typeModel.ClrName + ".generated.cs"); - File.WriteAllText(filename, sb.ToString()); - } - - // the idea was to calculate the current hash and to add it as an extra file to the compilation, - // in order to be able to detect whether a DLL is consistent with an environment - however the - // environment *might not* contain the local partial files, and thus it could be impossible to - // calculate the hash. So... maybe that's not a good idea after all? - /* - var currentHash = HashHelper.Hash(ourFiles, typeModels); - ourFiles["models.hash.cs"] = $@"using Umbraco.ModelsBuilder; -[assembly:ModelsBuilderAssembly(SourceHash = ""{currentHash}"")] -"; - */ - - if (bin != null) - { - foreach (var file in Directory.GetFiles(modelsDirectory, "*.generated.cs")) - ourFiles[file] = File.ReadAllText(file); - var compiler = new Compiler(); - compiler.Compile(builder.GetModelsNamespace(), ourFiles, bin); - } - - OutOfDateModelsStatus.Clear(); - } - - [DataContract] - internal class BuildResult - { - [DataMember(Name = "success")] - public bool Success; - [DataMember(Name = "message")] - public string Message; - } - - [DataContract] - internal class Dashboard - { - [DataMember(Name = "enable")] - public bool Enable; - [DataMember(Name = "text")] - public string Text; - [DataMember(Name = "canGenerate")] - public bool CanGenerate; - [DataMember(Name = "generateCausesRestart")] - public bool GenerateCausesRestart; - [DataMember(Name = "outOfDateModels")] - public bool OutOfDateModels; - [DataMember(Name = "lastError")] - public string LastError; - } - - internal enum OutOfDateType - { - OutOfDate, - Current, - Unknown = 100 - } - - [DataContract] - internal class OutOfDateStatus - { - [DataMember(Name = "status")] - public OutOfDateType Status { get; set; } - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderComponent.cs b/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderComponent.cs deleted file mode 100644 index a581319ba5..0000000000 --- a/src/Umbraco.ModelsBuilder/Umbraco/ModelsBuilderComponent.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Web; -using System.Web.Mvc; -using System.Web.Routing; -using LightInject; -using Umbraco.Core; -using Umbraco.Core.Components; -using Umbraco.Core.Composing; -using Umbraco.Core.Configuration; -using Umbraco.Core.IO; -using Umbraco.Core.Models.PublishedContent; -using Umbraco.Core.Services; -using Umbraco.Core.Services.Implement; -using Umbraco.ModelsBuilder.Api; -using Umbraco.ModelsBuilder.Configuration; -using Umbraco.Web; -using Umbraco.Web.PublishedCache.NuCache; -using Umbraco.Web.UI.JavaScript; - -namespace Umbraco.ModelsBuilder.Umbraco -{ - [RequiredComponent(typeof(NuCacheComponent))] - [RuntimeLevel(MinLevel = RuntimeLevel.Run)] - public class ModelsBuilderComponent : UmbracoComponentBase, IUmbracoCoreComponent - { - public override void Compose(Composition composition) - { - base.Compose(composition); - - composition.Container.Register(new PerContainerLifetime()); - - var config = UmbracoConfig.For.ModelsBuilder(); - - if (config.ModelsMode == ModelsMode.PureLive) - ComposeForLiveModels(composition.Container); - else if (config.EnableFactory) - ComposeForDefaultModelsFactory(composition.Container); - - // always setup the dashboard - InstallServerVars(composition.Container.GetInstance().Level); - composition.Container.Register(typeof(ModelsBuilderBackOfficeController), new PerRequestLifeTime()); - - // setup the API if enabled (and in debug mode) - if (config.ApiServer) - composition.Container.Register(typeof(ModelsBuilderApiController), new PerRequestLifeTime()); - } - - public void Initialize(UmbracoServices umbracoServices) - { - var config = UmbracoConfig.For.ModelsBuilder(); - - if (config.Enable) - FileService.SavingTemplate += FileService_SavingTemplate; - - // fixme LiveModelsProvider should not be static - if (config.ModelsMode.IsLiveNotPure()) - LiveModelsProvider.Install(umbracoServices); - - // fixme OutOfDateModelsStatus should not be static - if (config.FlagOutOfDateModels) - OutOfDateModelsStatus.Install(); - } - - private void ComposeForDefaultModelsFactory(IServiceContainer container) - { - container.RegisterSingleton(factory - => new PublishedModelFactory(factory.GetInstance().GetTypes())); - } - - private void ComposeForLiveModels(IServiceContainer container) - { - container.RegisterSingleton(); - - // the following would add @using statement in every view so user's don't - // have to do it - however, then noone understands where the @using statement - // comes from, and it cannot be avoided / removed --- DISABLED - // - /* - // no need for @using in views - // note: - // we are NOT using the in-code attribute here, config is required - // because that would require parsing the code... and what if it changes? - // we can AddGlobalImport not sure we can remove one anyways - var modelsNamespace = Configuration.Config.ModelsNamespace; - if (string.IsNullOrWhiteSpace(modelsNamespace)) - modelsNamespace = Configuration.Config.DefaultModelsNamespace; - System.Web.WebPages.Razor.WebPageRazorHost.AddGlobalImport(modelsNamespace); - */ - } - - private void InstallServerVars(RuntimeLevel level) - { - // register our url - for the backoffice api - ServerVariablesParser.Parsing += (sender, serverVars) => - { - if (!serverVars.ContainsKey("umbracoUrls")) - throw new Exception("Missing umbracoUrls."); - var umbracoUrlsObject = serverVars["umbracoUrls"]; - if (umbracoUrlsObject == null) - throw new Exception("Null umbracoUrls"); - if (!(umbracoUrlsObject is Dictionary umbracoUrls)) - throw new Exception("Invalid umbracoUrls"); - - if (!serverVars.ContainsKey("umbracoPlugins")) - throw new Exception("Missing umbracoPlugins."); - if (!(serverVars["umbracoPlugins"] is Dictionary umbracoPlugins)) - throw new Exception("Invalid umbracoPlugins"); - - if (HttpContext.Current == null) throw new InvalidOperationException("HttpContext is null"); - var urlHelper = new UrlHelper(new RequestContext(new HttpContextWrapper(HttpContext.Current), new RouteData())); - - umbracoUrls["modelsBuilderBaseUrl"] = urlHelper.GetUmbracoApiServiceBaseUrl(controller => controller.BuildModels()); - umbracoPlugins["modelsBuilder"] = GetModelsBuilderSettings(level); - }; - } - - private Dictionary GetModelsBuilderSettings(RuntimeLevel level) - { - if (level != RuntimeLevel.Run) - return null; - - var settings = new Dictionary - { - {"enabled", UmbracoConfig.For.ModelsBuilder().Enable} - }; - - return settings; - } - - /// - /// Used to check if a template is being created based on a document type, in this case we need to - /// ensure the template markup is correct based on the model name of the document type - /// - /// - /// - private void FileService_SavingTemplate(IFileService sender, Core.Events.SaveEventArgs e) - { - // don't do anything if the factory is not enabled - // because, no factory = no models (even if generation is enabled) - if (!UmbracoConfig.For.ModelsBuilder().EnableFactory) return; - - // don't do anything if this special key is not found - if (!e.AdditionalData.ContainsKey("CreateTemplateForContentType")) return; - - // ensure we have the content type alias - if (!e.AdditionalData.ContainsKey("ContentTypeAlias")) - throw new InvalidOperationException("The additionalData key: ContentTypeAlias was not found"); - - foreach (var template in e.SavedEntities) - { - // if it is in fact a new entity (not been saved yet) and the "CreateTemplateForContentType" key - // is found, then it means a new template is being created based on the creation of a document type - if (!template.HasIdentity && string.IsNullOrWhiteSpace(template.Content)) - { - // ensure is safe and always pascal cased, per razor standard - // + this is how we get the default model name in Umbraco.ModelsBuilder.Umbraco.Application - var alias = e.AdditionalData["ContentTypeAlias"].ToString(); - var name = template.Name; // will be the name of the content type since we are creating - var className = UmbracoServices.GetClrName(name, alias); - - var modelNamespace = UmbracoConfig.For.ModelsBuilder().ModelsNamespace; - - // we do not support configuring this at the moment, so just let Umbraco use its default value - //var modelNamespaceAlias = ...; - - var markup = ViewHelper.GetDefaultFileContent( - modelClassName: className, - modelNamespace: modelNamespace/*, - modelNamespaceAlias: modelNamespaceAlias*/); - - //set the template content to the new markup - template.Content = markup; - } - } - } - } -} diff --git a/src/Umbraco.ModelsBuilder/Validation/ContentTypeModelValidator.cs b/src/Umbraco.ModelsBuilder/Validation/ContentTypeModelValidator.cs deleted file mode 100644 index 20f5e94b64..0000000000 --- a/src/Umbraco.ModelsBuilder/Validation/ContentTypeModelValidator.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using Umbraco.Core; -using Umbraco.Core.Configuration; -using Umbraco.Core.Models; -using Umbraco.Core.Models.PublishedContent; -using Umbraco.ModelsBuilder.Configuration; -using Umbraco.Web.Editors; -using Umbraco.Web.Models.ContentEditing; - -namespace Umbraco.ModelsBuilder.Validation -{ - /// - /// Used to validate the aliases for the content type when MB is enabled to ensure that - /// no illegal aliases are used - /// - internal class ContentTypeModelValidator : ContentTypeModelValidatorBase - { - } - - /// - /// Used to validate the aliases for the content type when MB is enabled to ensure that - /// no illegal aliases are used - /// - internal class MediaTypeModelValidator : ContentTypeModelValidatorBase - { - } - - /// - /// Used to validate the aliases for the content type when MB is enabled to ensure that - /// no illegal aliases are used - /// - internal class MemberTypeModelValidator : ContentTypeModelValidatorBase - { - } - - internal abstract class ContentTypeModelValidatorBase : EditorValidator - where TModel: ContentTypeSave - where TProperty: PropertyTypeBasic - { - protected override IEnumerable Validate(TModel model) - { - //don't do anything if we're not enabled - if (UmbracoConfig.For.ModelsBuilder().Enable) - { - var properties = model.Groups.SelectMany(x => x.Properties) - .Where(x => x.Inherited == false) - .ToArray(); - - foreach (var prop in properties) - { - var propertyGroup = model.Groups.Single(x => x.Properties.Contains(prop)); - - if (model.Alias.ToLowerInvariant() == prop.Alias.ToLowerInvariant()) - yield return new ValidationResult(string.Format("With Models Builder enabled, you can't have a property with a the alias \"{0}\" when the content type alias is also \"{0}\".", prop.Alias), new[] - { - string.Format("Groups[{0}].Properties[{1}].Alias", model.Groups.IndexOf(propertyGroup), propertyGroup.Properties.IndexOf(prop)) - }); - - //we need to return the field name with an index so it's wired up correctly - var groupIndex = model.Groups.IndexOf(propertyGroup); - var propertyIndex = propertyGroup.Properties.IndexOf(prop); - - var validationResult = ValidateProperty(prop, groupIndex, propertyIndex); - if (validationResult != null) - { - yield return validationResult; - } - } - } - } - - private ValidationResult ValidateProperty(PropertyTypeBasic property, int groupIndex, int propertyIndex) - { - //don't let them match any properties or methods in IPublishedContent - //TODO: There are probably more! - var reservedProperties = typeof(IPublishedContent).GetProperties().Select(x => x.Name).ToArray(); - var reservedMethods = typeof(IPublishedContent).GetMethods().Select(x => x.Name).ToArray(); - - var alias = property.Alias; - - if (reservedProperties.InvariantContains(alias) || reservedMethods.InvariantContains(alias)) - { - return new ValidationResult( - string.Format("The alias {0} is a reserved term and cannot be used", alias), new[] - { - string.Format("Groups[{0}].Properties[{1}].Alias", groupIndex, propertyIndex) - }); - } - - return null; - } - } -} diff --git a/src/Umbraco.TestData/Properties/AssemblyInfo.cs b/src/Umbraco.TestData/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..3c4251cdf6 --- /dev/null +++ b/src/Umbraco.TestData/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Umbraco.TestData")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Umbraco.TestData")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("fb5676ed-7a69-492c-b802-e7b24144c0fc")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/src/Umbraco.TestData/SegmentTestController.cs b/src/Umbraco.TestData/SegmentTestController.cs new file mode 100644 index 0000000000..650820760e --- /dev/null +++ b/src/Umbraco.TestData/SegmentTestController.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Web.Mvc; +using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Web.Mvc; + +namespace Umbraco.TestData +{ + public class SegmentTestController : SurfaceController + { + + public ActionResult EnableDocTypeSegments(string alias, string propertyTypeAlias) + { + if (ConfigurationManager.AppSettings["Umbraco.TestData.Enabled"] != "true") + return HttpNotFound(); + + var ct = Services.ContentTypeService.Get(alias); + if (ct == null) + return Content($"No document type found by alias {alias}"); + + var propType = ct.PropertyTypes.FirstOrDefault(x => x.Alias == propertyTypeAlias); + if (propType == null) + return Content($"The document type {alias} does not have a property type {propertyTypeAlias ?? "null"}"); + + if (ct.Variations.VariesBySegment()) + return Content($"The document type {alias} already allows segments, nothing has been changed"); + + ct.SetVariesBy(ContentVariation.Segment); + propType.SetVariesBy(ContentVariation.Segment); + + Services.ContentTypeService.Save(ct); + return Content($"The document type {alias} and property type {propertyTypeAlias} now allows segments"); + } + + public ActionResult DisableDocTypeSegments(string alias) + { + if (ConfigurationManager.AppSettings["Umbraco.TestData.Enabled"] != "true") + return HttpNotFound(); + + var ct = Services.ContentTypeService.Get(alias); + if (ct == null) + return Content($"No document type found by alias {alias}"); + + if (!ct.VariesBySegment()) + return Content($"The document type {alias} does not allow segments, nothing has been changed"); + + ct.SetVariesBy(ContentVariation.Segment, false); + + Services.ContentTypeService.Save(ct); + return Content($"The document type {alias} no longer allows segments"); + } + + public ActionResult AddSegmentData(int contentId, string propertyAlias, string value, string segment, string culture = null) + { + var content = Services.ContentService.GetById(contentId); + if (content == null) + return Content($"No content found by id {contentId}"); + + if (propertyAlias.IsNullOrWhiteSpace() || !content.HasProperty(propertyAlias)) + return Content($"The content by id {contentId} does not contain a property with alias {propertyAlias ?? "null"}"); + + if (content.ContentType.VariesByCulture() && culture.IsNullOrWhiteSpace()) + return Content($"The content by id {contentId} varies by culture but no culture was specified"); + + if (value.IsNullOrWhiteSpace()) + return Content("'value' cannot be null"); + + if (segment.IsNullOrWhiteSpace()) + return Content("'segment' cannot be null"); + + content.SetValue(propertyAlias, value, culture, segment); + Services.ContentService.Save(content); + + return Content($"Segment value has been set on content {contentId} for property {propertyAlias}"); + } + } +} diff --git a/src/Umbraco.TestData/Umbraco.TestData.csproj b/src/Umbraco.TestData/Umbraco.TestData.csproj new file mode 100644 index 0000000000..d61321ebb8 --- /dev/null +++ b/src/Umbraco.TestData/Umbraco.TestData.csproj @@ -0,0 +1,70 @@ + + + + + Debug + AnyCPU + {FB5676ED-7A69-492C-B802-E7B24144C0FC} + Library + Properties + Umbraco.TestData + Umbraco.TestData + v4.7.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + + {31785bc3-256c-4613-b2f5-a1b0bdded8c1} + Umbraco.Core + + + {651e1350-91b6-44b7-bd60-7207006d7003} + Umbraco.Web + + + + + 28.4.4 + + + 5.2.7 + + + + \ No newline at end of file diff --git a/src/Umbraco.TestData/UmbracoTestDataController.cs b/src/Umbraco.TestData/UmbracoTestDataController.cs new file mode 100644 index 0000000000..402c05cc1c --- /dev/null +++ b/src/Umbraco.TestData/UmbracoTestDataController.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Umbraco.Core; +using System.Web.Mvc; +using Umbraco.Core.Cache; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence; +using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Services; +using Umbraco.Web; +using Umbraco.Web.Mvc; +using System.Configuration; +using Bogus; +using Umbraco.Core.Scoping; + +namespace Umbraco.TestData +{ + /// + /// Creates test data + /// + public class UmbracoTestDataController : SurfaceController + { + private const string RichTextDataTypeName = "UmbracoTestDataContent.RTE"; + private const string MediaPickerDataTypeName = "UmbracoTestDataContent.MediaPicker"; + private const string TextDataTypeName = "UmbracoTestDataContent.Text"; + private const string TestDataContentTypeAlias = "umbTestDataContent"; + private readonly IScopeProvider _scopeProvider; + private readonly PropertyEditorCollection _propertyEditors; + + public UmbracoTestDataController(IScopeProvider scopeProvider, PropertyEditorCollection propertyEditors, IUmbracoContextAccessor umbracoContextAccessor, IUmbracoDatabaseFactory databaseFactory, ServiceContext services, AppCaches appCaches, ILogger logger, IProfilingLogger profilingLogger, UmbracoHelper umbracoHelper) : base(umbracoContextAccessor, databaseFactory, services, appCaches, logger, profilingLogger, umbracoHelper) + { + _scopeProvider = scopeProvider; + _propertyEditors = propertyEditors; + } + + /// + /// Creates a content and associated media tree (hierarchy) + /// + /// + /// + /// + /// + /// + /// Each content item created is associated to a media item via a media picker and therefore a relation is created between the two + /// + public ActionResult CreateTree(int count, int depth, string locale = "en") + { + if (ConfigurationManager.AppSettings["Umbraco.TestData.Enabled"] != "true") + return HttpNotFound(); + + if (!Validate(count, depth, out var message, out var perLevel)) + throw new InvalidOperationException(message); + + var faker = new Faker(locale); + var company = faker.Company.CompanyName(); + + using (var scope = _scopeProvider.CreateScope()) + { + var imageIds = CreateMediaTree(company, faker, count, depth).ToList(); + var contentIds = CreateContentTree(company, faker, count, depth, imageIds, out var root).ToList(); + + Services.ContentService.SaveAndPublishBranch(root, true); + + scope.Complete(); + } + + + return Content("Done"); + } + + private bool Validate(int count, int depth, out string message, out int perLevel) + { + perLevel = 0; + message = null; + + if (count <= 0) + { + message = "Count must be more than 0"; + return false; + } + + perLevel = count / depth; + if (perLevel < 1) + { + message = "Count not high enough for specified for number of levels required"; + return false; + } + + return true; + } + + /// + /// Utility to create a tree hierarchy + /// + /// + /// + /// + /// + /// + /// A callback that returns a tuple of Content and another callback to produce a Container. + /// For media, a container will be another folder, for content the container will be the Content itself. + /// + /// + private IEnumerable CreateHierarchy( + T parent, int count, int depth, + Func container)> create) + where T: class, IContentBase + { + yield return parent.GetUdi(); + + // This will not calculate a balanced tree but it will ensure that there will be enough nodes deep enough to not fill up the tree. + var totalDescendants = count - 1; + var perLevel = Math.Ceiling(totalDescendants / (double)depth); + var perBranch = Math.Ceiling(perLevel / depth); + + var tracked = new Stack<(T parent, int childCount)>(); + + var currChildCount = 0; + + for (int i = 0; i < count; i++) + { + var created = create(parent); + var contentItem = created.content; + + yield return contentItem.GetUdi(); + + currChildCount++; + + if (currChildCount == perBranch) + { + // move back up... + + var prev = tracked.Pop(); + + // restore child count + currChildCount = prev.childCount; + // restore the parent + parent = prev.parent; + + } + else if (contentItem.Level < depth) + { + // track the current parent and it's current child count + tracked.Push((parent, currChildCount)); + + // not at max depth, create below + parent = created.container(); + + currChildCount = 0; + } + + } + } + + /// + /// Creates the media tree hiearachy + /// + /// + /// + /// + /// + /// + private IEnumerable CreateMediaTree(string company, Faker faker, int count, int depth) + { + var parent = Services.MediaService.CreateMediaWithIdentity(company, -1, Constants.Conventions.MediaTypes.Folder); + + return CreateHierarchy(parent, count, depth, currParent => + { + var imageUrl = faker.Image.PicsumUrl(); + + // we are appending a &ext=.jpg to the end of this for a reason. The result of this url will be something like: + // https://picsum.photos/640/480/?image=106 + // and due to the way that we detect images there must be an extension so we'll change it to + // https://picsum.photos/640/480/?image=106&ext=.jpg + // which will trick our app into parsing this and thinking it's an image ... which it is so that's good. + // if we don't do this we don't get thumbnails in the back office. + imageUrl += "&ext=.jpg"; + + var media = Services.MediaService.CreateMedia(faker.Commerce.ProductName(), currParent, Constants.Conventions.MediaTypes.Image); + media.SetValue(Constants.Conventions.Media.File, imageUrl); + Services.MediaService.Save(media); + return (media, () => + { + // create a folder to contain child media + var container = Services.MediaService.CreateMediaWithIdentity(faker.Commerce.Department(), currParent, Constants.Conventions.MediaTypes.Folder); + return container; + }); + }); + } + + /// + /// Creates the content tree hiearachy + /// + /// + /// + /// + /// + /// + /// + private IEnumerable CreateContentTree(string company, Faker faker, int count, int depth, List imageIds, out IContent root) + { + var random = new Random(company.GetHashCode()); + + var docType = GetOrCreateContentType(); + + var parent = Services.ContentService.Create(company, -1, docType.Alias); + parent.SetValue("review", faker.Rant.Review()); + parent.SetValue("desc", company); + parent.SetValue("media", imageIds[random.Next(0, imageIds.Count - 1)]); + Services.ContentService.Save(parent); + + root = parent; + + return CreateHierarchy(parent, count, depth, currParent => + { + var content = Services.ContentService.Create(faker.Commerce.ProductName(), currParent, docType.Alias); + content.SetValue("review", faker.Rant.Review()); + content.SetValue("desc", string.Join(", ", Enumerable.Range(0, 5).Select(x => faker.Commerce.ProductAdjective()))); ; + content.SetValue("media", imageIds[random.Next(0, imageIds.Count - 1)]); + + Services.ContentService.Save(content); + return (content, () => content); + }); + + } + + private IContentType GetOrCreateContentType() + { + var docType = Services.ContentTypeService.Get(TestDataContentTypeAlias); + if (docType != null) + return docType; + + docType = new ContentType(-1) + { + Alias = TestDataContentTypeAlias, + Name = "Umbraco Test Data Content", + Icon = "icon-science color-green" + }; + docType.AddPropertyGroup("Content"); + docType.AddPropertyType(new PropertyType(GetOrCreateRichText(), "review") + { + Name = "Review" + }); + docType.AddPropertyType(new PropertyType(GetOrCreateMediaPicker(), "media") + { + Name = "Media" + }); + docType.AddPropertyType(new PropertyType(GetOrCreateText(), "desc") + { + Name = "Description" + }); + Services.ContentTypeService.Save(docType); + docType.AllowedContentTypes = new[] { new ContentTypeSort(docType.Id, 0) }; + Services.ContentTypeService.Save(docType); + return docType; + } + + private IDataType GetOrCreateRichText() => GetOrCreateDataType(RichTextDataTypeName, Constants.PropertyEditors.Aliases.TinyMce); + + private IDataType GetOrCreateMediaPicker() => GetOrCreateDataType(MediaPickerDataTypeName, Constants.PropertyEditors.Aliases.MediaPicker); + + private IDataType GetOrCreateText() => GetOrCreateDataType(TextDataTypeName, Constants.PropertyEditors.Aliases.TextBox); + + private IDataType GetOrCreateDataType(string name, string editorAlias) + { + var dt = Services.DataTypeService.GetDataType(name); + if (dt != null) return dt; + + var editor = _propertyEditors.FirstOrDefault(x => x.Alias == editorAlias); + if (editor == null) + throw new InvalidOperationException($"No {editorAlias} editor found"); + + dt = new DataType(editor) + { + Name = name, + Configuration = editor.GetConfigurationEditor().DefaultConfigurationObject, + DatabaseType = ValueStorageType.Ntext + }; + + Services.DataTypeService.Save(dt); + return dt; + } + } +} diff --git a/src/Umbraco.TestData/readme.md b/src/Umbraco.TestData/readme.md new file mode 100644 index 0000000000..f943326303 --- /dev/null +++ b/src/Umbraco.TestData/readme.md @@ -0,0 +1,51 @@ +## Umbraco Test Data + +This project is a utility to be able to generate large amounts of content and media in an +Umbraco installation for testing. + +Currently this project is referenced in the Umbraco.Web.UI project but only when it's being built +in Debug mode (i.e. when testing within Visual Studio). + +## Usage + +You must use SQL Server for this, using SQLCE will die if you try to bulk create huge amounts of data. + +It has to be enabled by an appSetting: + +```xml + +``` + +Once this is enabled this endpoint can be executed: + +`/umbraco/surface/umbracotestdata/CreateTree?count=100&depth=5` + +The query string options are: + +* `count` = the number of content and media nodes to create +* `depth` = how deep the trees created will be +* `locale` (optional, default = "en") = the language that the data will be generated in + +This creates a content and associated media tree (hierarchy). Each content item created is associated +to a media item via a media picker and therefore a relation is created between the two. Each content and +media tree created have the same root node name so it's easy to know which content branch relates to +which media branch. + +All values are generated using the very handy `Bogus` package. + +## Schema + +This will install some schema items: + +* `umbTestDataContent` Document Type. __TIP__: If you want to delete all of the content data generated with this tool, just delete this content type +* `UmbracoTestDataContent.RTE` Data Type +* `UmbracoTestDataContent.MediaPicker` Data Type +* `UmbracoTestDataContent.Text` Data Type + +For media, the normal folder and image is used + +## Media + +This does not upload physical files, it just uses a randomized online image as the `umbracoFile` value. +This works when viewing the media item in the media section and the image will show up and with recent changes this will also work +when editing content to view the thumbnail for the picked media. diff --git a/src/Umbraco.Tests/Cache/RefresherTests.cs b/src/Umbraco.Tests/Cache/RefresherTests.cs index b1d0ad7b1a..eb8580c9e2 100644 --- a/src/Umbraco.Tests/Cache/RefresherTests.cs +++ b/src/Umbraco.Tests/Cache/RefresherTests.cs @@ -12,20 +12,22 @@ namespace Umbraco.Tests.Cache [Test] public void MediaCacheRefresherCanDeserializeJsonPayload() { - var source = new[] { new MediaCacheRefresher.JsonPayload(1234, TreeChangeTypes.None) }; + var source = new[] { new MediaCacheRefresher.JsonPayload(1234, Guid.NewGuid(), TreeChangeTypes.None) }; var json = JsonConvert.SerializeObject(source); var payload = JsonConvert.DeserializeObject(json); Assert.AreEqual(source[0].Id, payload[0].Id); + Assert.AreEqual(source[0].Key, payload[0].Key); Assert.AreEqual(source[0].ChangeTypes, payload[0].ChangeTypes); } [Test] public void ContentCacheRefresherCanDeserializeJsonPayload() { - var source = new[] { new ContentCacheRefresher.JsonPayload(1234, TreeChangeTypes.None) }; + var source = new[] { new ContentCacheRefresher.JsonPayload(1234, Guid.NewGuid(), TreeChangeTypes.None) }; var json = JsonConvert.SerializeObject(source); var payload = JsonConvert.DeserializeObject(json); Assert.AreEqual(source[0].Id, payload[0].Id); + Assert.AreEqual(source[0].Key, payload[0].Key); Assert.AreEqual(source[0].ChangeTypes, payload[0].ChangeTypes); } diff --git a/src/Umbraco.Tests/Cache/SnapDictionaryTests.cs b/src/Umbraco.Tests/Cache/SnapDictionaryTests.cs index b435af9e77..00ba721bdb 100644 --- a/src/Umbraco.Tests/Cache/SnapDictionaryTests.cs +++ b/src/Umbraco.Tests/Cache/SnapDictionaryTests.cs @@ -223,7 +223,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; - + // gen 1 d.Set(1, "one"); Assert.AreEqual(1, d.Test.GetValues(1).Length); @@ -321,7 +321,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; - + Assert.AreEqual(0, d.Test.GetValues(1).Length); // gen 1 @@ -416,7 +416,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; - + // gen 1 d.Set(1, "one"); Assert.AreEqual(1, d.Test.GetValues(1).Length); @@ -578,7 +578,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; - + d.Set(1, "one"); d.Set(2, "two"); @@ -689,7 +689,7 @@ namespace Umbraco.Tests.Cache { // gen 3 Assert.AreEqual(2, d.Test.GetValues(1).Length); - d.Set(1, "ein"); + d.SetLocked(1, "ein"); Assert.AreEqual(3, d.Test.GetValues(1).Length); Assert.AreEqual(3, d.Test.LiveGen); @@ -727,31 +727,25 @@ namespace Umbraco.Tests.Cache using (var w1 = d.GetScopedWriteLock(scopeProvider)) { Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); Assert.IsTrue(t.NextGen); - using (var w2 = d.GetScopedWriteLock(scopeProvider)) + Assert.Throws(() => { - Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(2, t.WLocked); - Assert.IsTrue(t.NextGen); - - Assert.AreNotSame(w1, w2); // get a new writer each time - - d.Set(1, "one"); - - Assert.AreEqual(0, d.CreateSnapshot().Gen); - } + using (var w2 = d.GetScopedWriteLock(scopeProvider)) + { + } + }); Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); Assert.IsTrue(t.NextGen); Assert.AreEqual(0, d.CreateSnapshot().Gen); } Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(0, t.WLocked); + Assert.IsFalse(t.IsLocked); Assert.IsTrue(t.NextGen); Assert.AreEqual(1, d.CreateSnapshot().Gen); @@ -772,11 +766,14 @@ namespace Umbraco.Tests.Cache using (var w1 = d.GetScopedWriteLock(scopeProvider)) { + // This one is interesting, although we don't allow recursive locks, since this is + // using the same ScopeContext/key, the lock acquisition is only done once + using (var w2 = d.GetScopedWriteLock(scopeProvider)) { Assert.AreSame(w1, w2); - d.Set(1, "one"); + d.SetLocked(1, "one"); } } } @@ -797,19 +794,16 @@ namespace Umbraco.Tests.Cache using (var w1 = d.GetScopedWriteLock(scopeProvider1)) { Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); Assert.IsTrue(t.NextGen); - using (var w2 = d.GetScopedWriteLock(scopeProvider2)) + Assert.Throws(() => { - Assert.AreEqual(1, t.LiveGen); - Assert.AreEqual(2, t.WLocked); - Assert.IsTrue(t.NextGen); + using (var w2 = d.GetScopedWriteLock(scopeProvider2)) + { + } + }); - Assert.AreNotSame(w1, w2); - - d.Set(1, "one"); - } } } @@ -848,13 +842,13 @@ namespace Umbraco.Tests.Cache Assert.IsFalse(d.Test.NextGen); Assert.AreEqual("uno", s2.Get(1)); - var scopeProvider = GetScopeProvider(); + var scopeProvider = GetScopeProvider(); using (d.GetScopedWriteLock(scopeProvider)) { // gen 3 Assert.AreEqual(2, d.Test.GetValues(1).Length); - d.Set(1, "ein"); + d.SetLocked(1, "ein"); Assert.AreEqual(3, d.Test.GetValues(1).Length); Assert.AreEqual(3, d.Test.LiveGen); @@ -881,6 +875,7 @@ namespace Umbraco.Tests.Cache { var d = new SnapDictionary(); d.Test.CollectAuto = false; + // gen 1 d.Set(1, "one"); @@ -894,12 +889,11 @@ namespace Umbraco.Tests.Cache Assert.AreEqual("uno", s2.Get(1)); var scopeProvider = GetScopeProvider(); - using (d.GetScopedWriteLock(scopeProvider)) { // creating a snapshot in a write-lock does NOT return the "current" content // it uses the previous snapshot, so new snapshot created only on release - d.Set(1, "ein"); + d.SetLocked(1, "ein"); var s3 = d.CreateSnapshot(); Assert.AreEqual(2, s3.Gen); Assert.AreEqual("uno", s3.Get(1)); @@ -934,12 +928,11 @@ namespace Umbraco.Tests.Cache var scopeContext = new ScopeContext(); var scopeProvider = GetScopeProvider(scopeContext); - using (d.GetScopedWriteLock(scopeProvider)) { // creating a snapshot in a write-lock does NOT return the "current" content // it uses the previous snapshot, so new snapshot created only on release - d.Set(1, "ein"); + d.SetLocked(1, "ein"); var s3 = d.CreateSnapshot(); Assert.AreEqual(2, s3.Gen); Assert.AreEqual("uno", s3.Get(1)); @@ -967,7 +960,7 @@ namespace Umbraco.Tests.Cache var d = new SnapDictionary(); var t = d.Test; t.CollectAuto = false; - + // gen 1 d.Set(1, "one"); var s1 = d.CreateSnapshot(); @@ -984,12 +977,11 @@ namespace Umbraco.Tests.Cache var scopeContext = new ScopeContext(); var scopeProvider = GetScopeProvider(scopeContext); - using (d.GetScopedWriteLock(scopeProvider)) { // creating a snapshot in a write-lock does NOT return the "current" content // it uses the previous snapshot, so new snapshot created only on release - d.Set(1, "ein"); + d.SetLocked(1, "ein"); var s3 = d.CreateSnapshot(); Assert.AreEqual(2, s3.Gen); Assert.AreEqual("uno", s3.Get(1)); @@ -997,7 +989,7 @@ namespace Umbraco.Tests.Cache // we made some changes, so a next gen is required Assert.AreEqual(3, t.LiveGen); Assert.IsTrue(t.NextGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); // but live snapshot contains changes var ls = t.LiveSnapshot; @@ -1008,7 +1000,7 @@ namespace Umbraco.Tests.Cache // nothing is committed until scope exits Assert.AreEqual(3, t.LiveGen); Assert.IsTrue(t.NextGen); - Assert.AreEqual(1, t.WLocked); + Assert.IsTrue(t.IsLocked); // no changes until exit var s4 = d.CreateSnapshot(); @@ -1020,7 +1012,7 @@ namespace Umbraco.Tests.Cache // now things have changed Assert.AreEqual(2, t.LiveGen); Assert.IsFalse(t.NextGen); - Assert.AreEqual(0, t.WLocked); + Assert.IsFalse(t.IsLocked); // no changes since not completed var s5 = d.CreateSnapshot(); @@ -1097,9 +1089,10 @@ namespace Umbraco.Tests.Cache // writer is scope contextual and scoped // when disposed, nothing happens // when the context exists, the writer is released + using (d.GetScopedWriteLock(scopeProvider)) { - d.Set(1, "ein"); + d.SetLocked(1, "ein"); Assert.IsTrue(d.Test.NextGen); Assert.AreEqual(3, d.Test.LiveGen); Assert.IsNotNull(d.Test.GenObj); @@ -1107,7 +1100,7 @@ namespace Umbraco.Tests.Cache } // writer has not released - Assert.AreEqual(1, d.Test.WLocked); + Assert.IsTrue(d.Test.IsLocked); Assert.IsNotNull(d.Test.GenObj); Assert.AreEqual(2, d.Test.GenObj.Gen); @@ -1118,7 +1111,7 @@ namespace Umbraco.Tests.Cache // panic! var s2 = d.CreateSnapshot(); - Assert.AreEqual(1, d.Test.WLocked); + Assert.IsTrue(d.Test.IsLocked); Assert.IsNotNull(d.Test.GenObj); Assert.AreEqual(2, d.Test.GenObj.Gen); Assert.AreEqual(3, d.Test.LiveGen); @@ -1127,7 +1120,7 @@ namespace Umbraco.Tests.Cache // release writer scopeContext.ScopeExit(true); - Assert.AreEqual(0, d.Test.WLocked); + Assert.IsFalse(d.Test.IsLocked); Assert.IsNotNull(d.Test.GenObj); Assert.AreEqual(2, d.Test.GenObj.Gen); Assert.AreEqual(3, d.Test.LiveGen); @@ -1135,7 +1128,7 @@ namespace Umbraco.Tests.Cache var s3 = d.CreateSnapshot(); - Assert.AreEqual(0, d.Test.WLocked); + Assert.IsFalse(d.Test.IsLocked); Assert.IsNotNull(d.Test.GenObj); Assert.AreEqual(3, d.Test.GenObj.Gen); Assert.AreEqual(3, d.Test.LiveGen); @@ -1150,4 +1143,45 @@ namespace Umbraco.Tests.Cache return scopeProvider; } } + + /// + /// Used for tests so that we don't have to wrap every Set/Clear call in locks + /// + public static class SnapDictionaryExtensions + { + internal static void Set(this SnapDictionary d, TKey key, TValue value) + where TValue : class + { + using (d.GetScopedWriteLock(GetScopeProvider())) + { + d.SetLocked(key, value); + } + } + + internal static void Clear(this SnapDictionary d) + where TValue : class + { + using (d.GetScopedWriteLock(GetScopeProvider())) + { + d.ClearLocked(); + } + } + + internal static void Clear(this SnapDictionary d, TKey key) + where TValue : class + { + using (d.GetScopedWriteLock(GetScopeProvider())) + { + d.ClearLocked(key); + } + } + + private static IScopeProvider GetScopeProvider() + { + var scopeProvider = Mock.Of(); + Mock.Get(scopeProvider) + .Setup(x => x.Context).Returns(() => null); + return scopeProvider; + } + } } diff --git a/src/Umbraco.Tests/Components/ComponentTests.cs b/src/Umbraco.Tests/Components/ComponentTests.cs index 2ba94d8c78..042cac1281 100644 --- a/src/Umbraco.Tests/Components/ComponentTests.cs +++ b/src/Umbraco.Tests/Components/ComponentTests.cs @@ -5,7 +5,6 @@ using Moq; using NUnit.Framework; using Umbraco.Core; using Umbraco.Core.Cache; -using Umbraco.Core.Compose; using Umbraco.Core.Composing; using Umbraco.Core.IO; using Umbraco.Core.Logging; @@ -13,8 +12,6 @@ using Umbraco.Core.Persistence; using Umbraco.Core.Persistence.Mappers; using Umbraco.Core.Scoping; -[assembly:DisableComposer(typeof(Umbraco.Tests.Components.ComponentTests.Composer26))] - namespace Umbraco.Tests.Components { [TestFixture] @@ -65,10 +62,11 @@ namespace Umbraco.Tests.Components public void Boot1A() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = TypeArray(); - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); // 2 is Core and requires 4 // 3 is User - goes away with RuntimeLevel.Unknown @@ -104,10 +102,11 @@ namespace Umbraco.Tests.Components public void Boot1B() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Run)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Run)); var types = TypeArray(); - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); // 2 is Core and requires 4 // 3 is User - stays with RuntimeLevel.Run @@ -120,10 +119,11 @@ namespace Umbraco.Tests.Components public void Boot2() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = TypeArray(); - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); // 21 is required by 20 // => reorder components accordingly @@ -135,10 +135,11 @@ namespace Umbraco.Tests.Components public void Boot3() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = TypeArray(); - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); // i23 requires 22 // 24, 25 implement i23 @@ -152,10 +153,11 @@ namespace Umbraco.Tests.Components public void BrokenRequire() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = TypeArray(); - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); try { @@ -175,10 +177,11 @@ namespace Umbraco.Tests.Components public void BrokenRequired() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = TypeArray(); - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); // 2 is Core and requires 4 // 13 is required by 1 @@ -196,6 +199,7 @@ namespace Umbraco.Tests.Components Terminated.Clear(); var register = MockRegister(); + var typeLoader = MockTypeLoader(); var factory = MockFactory(m => { m.Setup(x => x.TryGetInstance(It.Is(t => t == typeof (ISomeResource)))).Returns(() => new SomeResource()); @@ -210,10 +214,10 @@ namespace Umbraco.Tests.Components throw new NotSupportedException(type.FullName); }); }); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = new[] { typeof(Composer1), typeof(Composer5), typeof(Composer5a) }; - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Assert.IsEmpty(Composed); composers.Compose(); @@ -236,10 +240,11 @@ namespace Umbraco.Tests.Components public void Requires1() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = new[] { typeof(Composer6), typeof(Composer7), typeof(Composer8) }; - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); composers.Compose(); Assert.AreEqual(2, Composed.Count); @@ -251,10 +256,11 @@ namespace Umbraco.Tests.Components public void Requires2A() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = new[] { typeof(Composer9), typeof(Composer2), typeof(Composer4) }; - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); composers.Compose(); Assert.AreEqual(2, Composed.Count); @@ -267,11 +273,12 @@ namespace Umbraco.Tests.Components public void Requires2B() { var register = MockRegister(); + var typeLoader = MockTypeLoader(); var factory = MockFactory(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Run)); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Run)); var types = new[] { typeof(Composer9), typeof(Composer2), typeof(Composer4) }; - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); composers.Compose(); var builder = composition.WithCollectionBuilder(); @@ -287,35 +294,36 @@ namespace Umbraco.Tests.Components public void WeakDependencies() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = new[] { typeof(Composer10) }; - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); composers.Compose(); Assert.AreEqual(1, Composed.Count); Assert.AreEqual(typeof(Composer10), Composed[0]); types = new[] { typeof(Composer11) }; - composers = new Composers(composition, types, Mock.Of()); + composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); Assert.Throws(() => composers.Compose()); Console.WriteLine("throws:"); - composers = new Composers(composition, types, Mock.Of()); + composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); var requirements = composers.GetRequirements(false); Console.WriteLine(Composers.GetComposersReport(requirements)); types = new[] { typeof(Composer2) }; - composers = new Composers(composition, types, Mock.Of()); + composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); Assert.Throws(() => composers.Compose()); Console.WriteLine("throws:"); - composers = new Composers(composition, types, Mock.Of()); + composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); requirements = composers.GetRequirements(false); Console.WriteLine(Composers.GetComposersReport(requirements)); types = new[] { typeof(Composer12) }; - composers = new Composers(composition, types, Mock.Of()); + composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); composers.Compose(); Assert.AreEqual(1, Composed.Count); @@ -326,10 +334,11 @@ namespace Umbraco.Tests.Components public void DisableMissing() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); var types = new[] { typeof(Composer6), typeof(Composer8) }; // 8 disables 7 which is not in the list - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); Composed.Clear(); composers.Compose(); Assert.AreEqual(2, Composed.Count); @@ -341,16 +350,18 @@ namespace Umbraco.Tests.Components public void AttributesPriorities() { var register = MockRegister(); - var composition = new Composition(register, MockTypeLoader(), Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); + var typeLoader = MockTypeLoader(); + var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Unknown)); - var types = new[] { typeof(Composer26) }; // 26 disabled by assembly attribute - var composers = new Composers(composition, types, Mock.Of()); + var types = new[] { typeof(Composer26) }; + var enableDisableAttributes = new[] { new DisableComposerAttribute(typeof(Composer26)) }; + var composers = new Composers(composition, types, enableDisableAttributes, Mock.Of()); Composed.Clear(); composers.Compose(); Assert.AreEqual(0, Composed.Count); // 26 gone types = new[] { typeof(Composer26), typeof(Composer27) }; // 26 disabled by assembly attribute, enabled by 27 - composers = new Composers(composition, types, Mock.Of()); + composers = new Composers(composition, types, enableDisableAttributes, Mock.Of()); Composed.Clear(); composers.Compose(); Assert.AreEqual(2, Composed.Count); // both @@ -367,7 +378,7 @@ namespace Umbraco.Tests.Components var composition = new Composition(register, typeLoader, Mock.Of(), MockRuntimeState(RuntimeLevel.Run)); var types = typeLoader.GetTypes().Where(x => x.FullName.StartsWith("Umbraco.Core.") || x.FullName.StartsWith("Umbraco.Web")); - var composers = new Composers(composition, types, Mock.Of()); + var composers = new Composers(composition, types, Enumerable.Empty(), Mock.Of()); var requirements = composers.GetRequirements(); var report = Composers.GetComposersReport(requirements); Console.WriteLine(report); @@ -506,7 +517,6 @@ namespace Umbraco.Tests.Components public class Composer25 : TestComposerBase, IComposer23 { } - // disabled by assembly attribute public class Composer26 : TestComposerBase { } diff --git a/src/Umbraco.Tests/Composing/CompositionTests.cs b/src/Umbraco.Tests/Composing/CompositionTests.cs index f4478e2add..33855a8bfb 100644 --- a/src/Umbraco.Tests/Composing/CompositionTests.cs +++ b/src/Umbraco.Tests/Composing/CompositionTests.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Composing; +using Umbraco.Core.IO; using Umbraco.Core.Logging; namespace Umbraco.Tests.Composing @@ -35,7 +36,7 @@ namespace Umbraco.Tests.Composing .Returns(() => factoryFactory?.Invoke(mockedFactory)); var logger = new ProfilingLogger(Mock.Of(), Mock.Of()); - var typeLoader = new TypeLoader(Mock.Of(), "", logger); + var typeLoader = new TypeLoader(Mock.Of(), IOHelper.MapPath("~/App_Data/TEMP"), logger); var composition = new Composition(mockedRegister, typeLoader, logger, Mock.Of()); // create the factory, ensure it is the mocked factory diff --git a/src/Umbraco.Tests/Composing/TypeHelperTests.cs b/src/Umbraco.Tests/Composing/TypeHelperTests.cs index 756ca4ca15..1f2477bf98 100644 --- a/src/Umbraco.Tests/Composing/TypeHelperTests.cs +++ b/src/Umbraco.Tests/Composing/TypeHelperTests.cs @@ -165,7 +165,7 @@ namespace Umbraco.Tests.Composing Assert.IsTrue(TypeHelper.MatchType(typeof(int?), typeof(Nullable<>))); - Assert.IsTrue(TypeHelper.MatchType(typeof(Derived), typeof(Object))); + Assert.IsTrue(TypeHelper.MatchType(typeof(Derived), typeof(object))); Assert.IsFalse(TypeHelper.MatchType(typeof(Derived), typeof(List<>))); Assert.IsFalse(TypeHelper.MatchType(typeof(Derived), typeof(IEnumerable<>))); Assert.IsTrue(TypeHelper.MatchType(typeof(Derived), typeof(Base))); diff --git a/src/Umbraco.Tests/Configurations/GlobalSettingsTests.cs b/src/Umbraco.Tests/Configurations/GlobalSettingsTests.cs index 54080f05de..50ead4b702 100644 --- a/src/Umbraco.Tests/Configurations/GlobalSettingsTests.cs +++ b/src/Umbraco.Tests/Configurations/GlobalSettingsTests.cs @@ -1,6 +1,4 @@ -using System.Web.Mvc; -using System.Web.Routing; -using Moq; +using Moq; using NUnit.Framework; using Umbraco.Core; using Umbraco.Core.Composing; @@ -10,6 +8,7 @@ using Umbraco.Tests.TestHelpers; namespace Umbraco.Tests.Configurations { + [TestFixture] public class GlobalSettingsTests : BaseWebTest { @@ -47,73 +46,18 @@ namespace Umbraco.Tests.Configurations [TestCase("~/some-wacky/nestedPath", "/MyVirtualDir/NestedVDir/", "some-wacky-nestedpath")] public void Umbraco_Mvc_Area(string path, string rootPath, string outcome) { - var globalSettingsMock = Mock.Get(Factory.GetInstance()); //this will modify the IGlobalSettings instance stored in the container - globalSettingsMock.Setup(x => x.Path).Returns(IOHelper.ResolveUrl(path)); + var globalSettings = SettingsForTests.GenerateMockGlobalSettings(); + + var globalSettingsMock = Mock.Get(globalSettings); + globalSettingsMock.Setup(x => x.Path).Returns(() => IOHelper.ResolveUrl(path)); SystemDirectories.Root = rootPath; - Assert.AreEqual(outcome, Current.Configs.Global().GetUmbracoMvcArea()); + Assert.AreEqual(outcome, globalSettings.GetUmbracoMvcAreaNoCache()); } - [TestCase("/umbraco/editContent.aspx")] - [TestCase("/install/default.aspx")] - [TestCase("/install/")] - [TestCase("/install")] - [TestCase("/install/?installStep=asdf")] - [TestCase("/install/test.aspx")] - public void Is_Reserved_Path_Or_Url(string url) - { - var globalSettings = TestObjects.GetGlobalSettings(); - Assert.IsTrue(globalSettings.IsReservedPathOrUrl(url)); - } - - [TestCase("/base/somebasehandler")] - [TestCase("/")] - [TestCase("/home.aspx")] - [TestCase("/umbraco-test")] - [TestCase("/install-test")] - [TestCase("/install.aspx")] - public void Is_Not_Reserved_Path_Or_Url(string url) - { - var globalSettings = TestObjects.GetGlobalSettings(); - Assert.IsFalse(globalSettings.IsReservedPathOrUrl(url)); - } + - [TestCase("/Do/Not/match", false)] - [TestCase("/Umbraco/RenderMvcs", false)] - [TestCase("/Umbraco/RenderMvc", true)] - [TestCase("/Umbraco/RenderMvc/Index", true)] - [TestCase("/Umbraco/RenderMvc/Index/1234", true)] - [TestCase("/Umbraco/RenderMvc/Index/1234/9876", false)] - [TestCase("/api", true)] - [TestCase("/api/WebApiTest", true)] - [TestCase("/api/WebApiTest/1234", true)] - [TestCase("/api/WebApiTest/Index/1234", false)] - public void Is_Reserved_By_Route(string url, bool shouldMatch) - { - //reset the app config, we only want to test routes not the hard coded paths - var globalSettingsMock = Mock.Get(Factory.GetInstance()); //this will modify the IGlobalSettings instance stored in the container - globalSettingsMock.Setup(x => x.ReservedPaths).Returns(""); - globalSettingsMock.Setup(x => x.ReservedUrls).Returns(""); - - var routes = new RouteCollection(); - - routes.MapRoute( - "Umbraco_default", - "Umbraco/RenderMvc/{action}/{id}", - new { controller = "RenderMvc", action = "Index", id = UrlParameter.Optional }); - routes.MapRoute( - "WebAPI", - "api/{controller}/{id}", - new { controller = "WebApiTestController", action = "Index", id = UrlParameter.Optional }); - - - var context = new FakeHttpContextFactory(url); - - - Assert.AreEqual( - shouldMatch, - globalSettingsMock.Object.IsReservedPathOrUrl(url, context.HttpContext, routes)); - } + } } diff --git a/src/Umbraco.Tests/Configurations/UmbracoSettings/ContentElementTests.cs b/src/Umbraco.Tests/Configurations/UmbracoSettings/ContentElementTests.cs index 26e6a1ad8a..0245159c6e 100644 --- a/src/Umbraco.Tests/Configurations/UmbracoSettings/ContentElementTests.cs +++ b/src/Umbraco.Tests/Configurations/UmbracoSettings/ContentElementTests.cs @@ -67,7 +67,7 @@ namespace Umbraco.Tests.Configurations.UmbracoSettings [Test] public void PreviewBadge() { - Assert.AreEqual(SettingsSection.Content.PreviewBadge, @"In Preview Mode - click to end"); + Assert.AreEqual(SettingsSection.Content.PreviewBadge, @"
Preview modeClick to end
"); } [Test] public void ResolveUrlsFromTextString() diff --git a/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config b/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config index ac022a5489..8cbb799d88 100644 --- a/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config +++ b/src/Umbraco.Tests/Configurations/UmbracoSettings/umbracoSettings.config @@ -41,9 +41,7 @@ - In Preview Mode - click to end - ]]> + Preview modeClick to end]]> - - At least {{installer.current.model.minCharLength}} characters long - - - At least {{installer.current.model.minNonAlphaNumericLength}} symbol{{installer.current.model.minNonAlphaNumericLength > 1 ? 's' : ''}} - +
+
+
+
+ +
+ +
+
+ +
+ +
+ + Your email will be used as your login +
+
+ +
+ +
+ + + At least {{installer.current.model.minCharLength}} characters long + + + At least {{installer.current.model.minNonAlphaNumericLength}} symbol{{installer.current.model.minNonAlphaNumericLength > 1 ? 's' : ''}} + +
-
-
-
- -
-
+
+
+ +
+
-
-
- - Customize -
-
+
+
+ + +
+
-
-
- - + + + + diff --git a/src/Umbraco.Web.UI.Client/src/installer/steps/version7upgradereport.html b/src/Umbraco.Web.UI.Client/src/installer/steps/version7upgradereport.html index df1e58d737..f30788e858 100644 --- a/src/Umbraco.Web.UI.Client/src/installer/steps/version7upgradereport.html +++ b/src/Umbraco.Web.UI.Client/src/installer/steps/version7upgradereport.html @@ -2,11 +2,11 @@

Major version upgrade from {{installer.current.model.currentVersion}} to {{installer.current.model.newVersion}}

There were {{installer.current.model.errors.length}} issues detected

- The following compatibility issues were found. If you continue all non-compatible property editors will be converted to a Readonly/Label. + The following compatibility issues were found. If you continue, all non-compatible property editors will be converted to a Readonly/Label. You will be able to change the property editor to a compatible type manually by editing the data type after installation.

- Otherwise if you choose not to proceed you will need to fix the errors listed below. + Otherwise, if you choose not to proceed, you will need to fix the errors listed below. Refer to v{{installer.current.model.newVersion}} upgrade instructions for full details.

@@ -19,4 +19,4 @@

- \ No newline at end of file + diff --git a/src/Umbraco.Web.UI.Client/src/less/application/umb-outline.less b/src/Umbraco.Web.UI.Client/src/less/application/umb-outline.less index 1a04dd10c8..939366d5ac 100644 --- a/src/Umbraco.Web.UI.Client/src/less/application/umb-outline.less +++ b/src/Umbraco.Web.UI.Client/src/less/application/umb-outline.less @@ -1,3 +1,7 @@ +*:focus { + outline-color: @ui-outline; +} + .umb-outline { &:focus { outline:none; @@ -10,7 +14,28 @@ left: 0; right: 0; border-radius: 3px; - box-shadow: 0 0 2px @blueMid, inset 0 0 2px 1px @blueMid; + box-shadow: 0 0 2px 0px @ui-outline, inset 0 0 2px 2px @ui-outline; } } + + &.umb-outline--surrounding { + &:focus { + .tabbing-active &::after { + top: -6px; + bottom: -6px; + left: -6px; + right: -6px; + border-radius: 9px; + } + } + } + + &.umb-outline--thin { + &:focus { + .tabbing-active &::after { + box-shadow: 0 0 2px @ui-outline, inset 0 0 2px 1px @ui-outline; + } + } + } + } diff --git a/src/Umbraco.Web.UI.Client/src/less/belle.less b/src/Umbraco.Web.UI.Client/src/less/belle.less index bd1cdd5b4f..0921f46aac 100644 --- a/src/Umbraco.Web.UI.Client/src/less/belle.less +++ b/src/Umbraco.Web.UI.Client/src/less/belle.less @@ -2,6 +2,7 @@ // Core variables and mixins @import "fonts.less"; // Loading fonts @import "variables.less"; // Modify this for custom colors, font-sizes, etc +@import "colors.less"; // Colors from variables but as specific CSS classes @import "mixins.less"; // CSS Reset @@ -74,9 +75,9 @@ @import "sections.less"; @import "helveticons.less"; @import "main.less"; -@import "listview.less"; +@import "listview.less"; @import "gridview.less"; -@import "footer.less"; +@import "filter-toggle.less"; @import "forms/umb-validation-label.less"; @@ -102,7 +103,6 @@ @import "components/tree/umb-actions.less"; @import "components/tree/umb-tree-item.less"; - @import "components/editor.less"; @import "components/overlays.less"; @import "components/card.less"; @@ -112,7 +112,7 @@ @import "components/umb-editor-navigation-item.less"; @import "components/umb-editor-sub-views.less"; @import "components/editor/subheader/umb-editor-sub-header.less"; -@import "components/umb-flatpickr.less"; +@import "components/umb-date-time-picker.less"; @import "components/umb-grid-selector.less"; @import "components/umb-child-selector.less"; @import "components/umb-group-builder.less"; @@ -124,12 +124,15 @@ @import "components/umb-form-check.less"; @import "components/umb-locked-field.less"; @import "components/umb-tabs.less"; +@import "components/umb-loader.less"; @import "components/umb-load-indicator.less"; @import "components/umb-breadcrumbs.less"; @import "components/umb-media-grid.less"; @import "components/umb-folder-grid.less"; @import "components/umb-content-grid.less"; +@import "components/umb-contextmenu.less"; @import "components/umb-layout-selector.less"; +@import "components/umb-mini-search.less"; @import "components/tooltip/umb-tooltip.less"; @import "components/tooltip/umb-tooltip-list.less"; @import "components/overlays/umb-overlay-backdrop.less"; @@ -137,8 +140,11 @@ @import "components/umb-grid.less"; @import "components/umb-empty-state.less"; @import "components/umb-property-editor.less"; +@import "components/umb-property-actions.less"; +@import "components/umb-code-snippet.less"; @import "components/umb-color-swatches.less"; @import "components/check-circle.less"; +@import "components/umb-file-icon.less"; @import "components/umb-iconpicker.less"; @import "components/umb-insert-code-box.less"; @import "components/umb-packages.less"; @@ -186,6 +192,10 @@ @import "components/users/umb-user-preview.less"; @import "components/users/umb-user-picker-list.less"; +@import "components/contextdialogs/umb-dialog-datatype-delete.less"; + +@import "components/umbemailmarketing.less"; + // Utilities @import "utilities/layout/_display.less"; @@ -200,7 +210,6 @@ @import "utilities/_cursor.less"; //page specific styles -@import "pages/document-type-editor.less"; @import "pages/login.less"; @import "pages/welcome-dashboard.less"; @@ -216,6 +225,7 @@ @import "dashboards/umbraco-forms.less"; @import "dashboards/examine-management.less"; @import "dashboards/healthcheck.less"; +@import "dashboards/nucache.less"; @import "typeahead.less"; @import "hacks.less"; diff --git a/src/Umbraco.Web.UI.Client/src/less/buttons.less b/src/Umbraco.Web.UI.Client/src/less/buttons.less index 91a6c29a17..2b50b60ae8 100644 --- a/src/Umbraco.Web.UI.Client/src/less/buttons.less +++ b/src/Umbraco.Web.UI.Client/src/less/buttons.less @@ -23,8 +23,7 @@ border-radius: 3px; // Hover/focus state - &:hover, - &:focus { + &:hover { background: @btnBackgroundHighlight; color: @gray-4; background-position: 0 -15px; @@ -35,11 +34,6 @@ .transition(background-position .1s linear); } - // Focus state for keyboard and accessibility - &:focus { - .tab-focus(); - } - // Active state &.active, &:active { @@ -54,7 +48,7 @@ &:disabled:hover { cursor: default; border-color: @btnBorder; - .opacity(65); + .opacity(80); .box-shadow(none); } @@ -219,7 +213,7 @@ input[type="button"] { } // Made for Umbraco, 2019, used for buttons that has to stand back. .btn-white { - .buttonBackground(@btnWhiteBackground, @btnWhiteBackgroundHighlight, @btnWhiteType, @btnWhiteTypeHover); + .buttonBackground(@btnWhiteBackground, @btnWhiteBackgroundHighlight, @btnWhiteType, @btnWhiteTypeHover, @gray-10, @gray-7); } // Inverse appears as dark gray .btn-inverse { @@ -230,8 +224,7 @@ input[type="button"] { .buttonBackground(@btnNeutralBackground, @btnNeutralBackgroundHighlight); color: @gray-5; // Hover/focus state - &:hover, - &:focus { + &:hover { color: @gray-5; } @@ -261,18 +254,18 @@ input[type="button"] { .btn-outline { border: 1px solid; border-color: @gray-7; - background: @white; + background: transparent; color: @blueExtraDark; padding: 5px 13px; - transition: all .2s linear; + transition: border-color .12s linear, color .12s linear; + font-weight: 600; } -.btn-outline:hover, -.btn-outline:focus, -.btn-outline:active { +.btn-outline:hover { border-color: @ui-light-type-hover; color: @ui-light-type-hover; - background: @white; + background: transparent; + transition: border-color .12s linear, color .12s linear; } // Cross-browser Jank @@ -309,14 +302,12 @@ input[type="submit"].btn { color: @linkColor; .border-radius(0); } -.btn-link:hover, -.btn-link:focus { +.btn-link:hover { color: @linkColorHover; text-decoration: underline; background-color: transparent; } -.btn-link[disabled]:hover, -.btn-link[disabled]:focus { +.btn-link[disabled]:hover { color: @gray-4; text-decoration: none; } @@ -324,8 +315,7 @@ input[type="submit"].btn { // Make a reverse type of a button link .btn-link-reverse{ text-decoration:underline; - &:hover, - &:focus{ + &:hover { text-decoration:none; } } @@ -338,3 +328,31 @@ input[type="submit"].btn { text-decoration: none; } } + +// Icon buttons +// ------------------------------ + +// 31 July 19, Nathan Woulfe says: Reset styles for cases where button shows an icon only (eg edit/remove property on document type) +// This is lifted from umb-group-builder.less + +.btn-icon { + border: none; + + font-size: 18px; + position: relative; + cursor: pointer; + color: @ui-icon; + + margin: 0; + padding: 5px 10px; + width: auto; + overflow: visible; + background: transparent; + line-height: normal; + outline: 0; + -webkit-appearance: none; + + &:hover { + color: @ui-icon-hover; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/canvas-designer.less b/src/Umbraco.Web.UI.Client/src/less/canvas-designer.less index 7440a5723a..7135692ae8 100644 --- a/src/Umbraco.Web.UI.Client/src/less/canvas-designer.less +++ b/src/Umbraco.Web.UI.Client/src/less/canvas-designer.less @@ -1,5 +1,7 @@ @import "helveticons.less"; +@import "variables.less"; +@import "application/umb-outline.less"; /******* font-face *******/ @@ -13,711 +15,197 @@ /****************************/ body { - overflow: hidden; - height: 100%; - width: 100%; - width: calc(~"100% - 80px"); // 80px is the fixed left menu for toggling the different browser sizes position: absolute; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + height: calc(~"100% - 40px"); + width: 100%; padding: 0; margin: 0; - font-family: "Lato", "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 14px; - line-height: 20px; - color: #343434; - transition: all 0.2s ease-in-out; - padding-left:80px; + padding-bottom:40px; + background-color: @brownGrayExtraLight; } -h4 { - margin: 0; - font-size: 16px; - background-color: #535353; - color: #b3b3b3; - text-transform: uppercase; - position: initial; - display: block; - padding: 5px 10px; - font-size: 12px; - font-weight: normal; -} - -h5 { - margin: 0 0 6px 0; - font-size: 12px; -} - -ul { - list-style:none; - margin:0; - padding:0; -} - -a, a:hover{ - color:#333; - text-decoration:none; -} - - /****************************/ /* General class */ /****************************/ -.right { - float:right; - display:inline-block; -} - -.left { - float:left; - display:inline-block; -} - -.leftOpen { - padding-left: 330px -} - -.wait { - display: block; - height: 100%; - width: 100%; - background:#fff center center url(../img/loader.gif) no-repeat; -} - -/****************************/ -/* Group button */ -/****************************/ - -.btn-group { - float: right; - margin-right: 10px; -} - -.btn { - display: inline-block; - padding: 4px 12px; - margin-bottom: 0; - font-size: 14px; - line-height: 20px; - color: #000000; - text-align: center; - vertical-align: middle; - cursor: pointer; - background: #f2f2f2; - border: 1px solid #cccccc; - border-radius: 2px; - box-shadow: none; -} - -.btn-group > .btn + .dropdown-toggle { - box-shadow: none; -} - -.btn-group > .btn + .btn { - margin-left: -6px; -} - -.btn-group > .btn + .dropdown-toggle { - padding-right: 8px; - padding-left: 8px; - box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.125), inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.btn-group > .btn:last-child, .btn-group > .dropdown-toggle { - border-top-right-radius: 2px; - border-bottom-right-radius: 2px; -} - -.caret { - display: inline-block; - width: 0; - height: 0; - vertical-align: top; - border-top: 4px solid #FFFFFF; - border-right: 4px solid transparent; - border-left: 4px solid transparent; - content: ""; - border-top: 0; - border-bottom: 4px solid #ffffff; - margin-top: 8px; - margin-left: 0; -} - -.dropdown-menu { +.menu-bar { position: absolute; - display: block; - top: auto; - right: 0; - z-index: 1000; - display: block; - float: left; - min-width: 160px; - padding: 5px 0; - margin: -96px 10px 0 0; - margin-bottom: 1px; - list-style: none; - background-color: #ffffff; - border: 1px solid #ccc; - border: 1px solid rgba(0, 0, 0, 0.2); - border-radius: 6px; - box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - background-clip: padding-box; -} - -.dropdown-menu > li > a { - display: block; - padding: 3px 20px; - clear: both; - font-weight: normal; - line-height: 20px; - color: black; - white-space: nowrap; - cursor:pointer; -} - -.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus, .dropdown-submenu:hover > a, .dropdown-submenu:focus > a { - color: #000000; - background: #e4e0dd; -} - -/****************************/ -/* Speech bubble */ -/****************************/ - -#speechbubble { - position: absolute; - right: 0; - bottom: 40px; + bottom:0; left: 0; - z-index: 9999; - display: none; - padding: 0; - margin: auto; - margin-left: 300px; - text-align: left; - background: none; - border: none; - border-bottom: none; -} + right: 0; + background-color: @blueExtraDark; + color:@white; -#speechbubble p { - position: relative; - padding: 8px 30px 8px 20px; - margin: auto; - margin-top: 5px; - font-size: 12px; - color: #ffffff; - text-shadow: none; - background-color: #46a546; - border: none; - border-color: transparent; - border-radius: 5px 0 0 5px; -} - - -/****************************/ -/* Main section menu */ -/****************************/ - -.more-options i { - display: inline-block; - width: 5px !important; - height: 5px !important; - margin: 5px 1px 7px 0; - background: #d9d9d9; - border-radius: 20px; -} - -.fix-left-menu { - position: fixed; - top: 0; - left: 0; - width: 80px; - height: 100%; - padding: 0; - margin-left: -80px; font-family: "Lato", Helvetica, Arial, sans-serif; - font-size: 13px; + font-size: 12px; line-height: 16px; - background: #1b264f; - transition: all 0.2s ease-in-out; - z-index: 9999; + + animation: menu-bar-animation 1.2s; + animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); + } -.avatar { - text-align:center; - padding: 27px 0 29px 0; - border-bottom: 1px solid #2E2246; +@keyframes menu-bar-animation { + 0% { + bottom: -50px; + } + 40% { + bottom: -50px; + } + 80% { + bottom: 0px; + } + } + +.menu-bar__right-part { + float: right; + display: flex; + flex: row; + + > div, > button { + border-left: 1px solid rgba(255, 255, 255, .25); + } } -.help { - position: absolute; - bottom: 0; - left: 0; - display: block; - width: 100%; - margin: 0; - font-size: 30px; - text-align: center; - color: #D8D7D9; - opacity: 0.4; - transition: all .3s linear; +.menu-bar__title { + display: inline-block; + padding: 11px 15px; + font-weight: bold; + font-size: 13px; } -ul.sections { - display: block; - background: #1b264f; - height: 100%; - position:absolute; - top: 90px; - width: 80px; - transition: all 0.2s ease-in-out; - list-style:none; - margin:0; - padding:0; - margin-left: -80px; -} +.menu-bar__button { + display: inline-block; + padding: 11px 15px; + height: 40px; + border:none; + background-color: @blueExtraDark; -ul.sections li { - display: block; - border-left: 4px #1b264f solid; - transition: all .3s linear; -} + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; -.fix-left-menu ul.sections li a span, -.fix-left-menu ul.sections li a i { - color: #fff; - opacity: .7; - transition: all .3s linear; -} + transition: color 120ms linear, background-color 120ms linear; + + .icon { + margin-right: 10px; + font-size: 18px; + vertical-align: middle; + } + + span { + vertical-align: middle; + } + + > svg { + display: inline-block; + width: 14px; + height: 14px; + fill: #fff; + margin-right: 10px; + vertical-align: middle; + transition: fill 120ms linear; + } -ul.sections li a { - display: block; - width: 100%; - height: 100%; - padding: 20px 4px 15px 0; - margin: 0 0 0 -4px; - text-align: center; - text-decoration: none; - border-bottom: 1px solid #2E2246; &:hover { - span, i { - opacity: 1; + background-color: lighten(@blueExtraDark, 4%); + } + &.--active { + color: @pinkLight; + > svg { + fill: @pinkLight; } } } -ul.sections li a i { - font-size: 30px; -} +.preview-menu-option { -ul.sections li a span { - display:block; - font-size: 10px; - line-height: 1.4em; - opacity: 0.4; -} - -ul.sections li.current { - background-color: #2E2246; -} - -ul.sections li.current a i { - color: #ffffff; -} - -ul.sections li.current, ul.sections li:hover { - border-left: 4px #f5c1bc solid; -} - -.fix-left-menu:hover ul.sections li a span, -.fix-left-menu:hover ul.sections li a i, -.fix-left-menu:hover .help { - opacity: 1; -} - -.fix-left-menu.selected, -.sections.selected { - margin-left:0px; -} - -/*************************************************/ -/* Main panel */ -/*************************************************/ - -.main-panel { - position: fixed; - top: 0; - left: 0; - margin-left: -330px; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 13px; - line-height: 16px; - background: #ffffff; - transition: all 0.2s ease-in-out; - width: 250px; - height: 100%; - padding: 0; - z-index: 999; - box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); -} - -.main-panel .header { - padding: 28px 20px 32px 20px; - font-weight: bold; - background: #f8f8f8; - border-bottom: 1px solid #d9d9d9; -} - -.main-panel .header h3 { - color: rgba(179, 179, 179, 0.49); - font-size: 24px; - margin:0; -} - -.main-panel .header h3 i { - position: absolute; - right: 20px; - cursor:pointer; - transition: all 0.2s ease-in-out; -} - -.main-panel .header h3 i:hover { - color:#333; -} - -.main-panel.selected { - margin-left: 80px; - position: absolute; - right: 0; - bottom: 0; - left: 0; - overflow: auto; -} - -.main-panel .content { - padding:20px 0; -} - -/*************************************************/ -/* float-panel */ -/*************************************************/ - -.float-panel { - position: fixed; - top: 0; - z-index: 99; - width: 250px; - height: 100%; - padding: 0; - padding: 20px; - margin-left: -480px; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 13px; - line-height: 16px; - background: #ffffff; - box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); - transition: all 0.2s ease-in-out; -} - -.float-panel.selected{ - margin-left: 0px; -} - -/*************************************************/ -/* sample palette color */ -/*************************************************/ - -.samples h4 { - position: initial; - display: block; - padding: 5px 10px; - margin: 0; - border: 0px solid #6B6B6B; - font-weight: normal; - font-size: 12px; -} - -.samples > li { - padding: 6px 20px; - cursor: pointer; -} - -.samples > li:hover, .samples > li.hover { - background: #f8f8f8; -} - -.samples > li ul { - display:table; - width:100%; -} - -.samples > li ul > li { - display: table-cell; - height: 15px; - padding: 0; -} - -/*************************************************/ -/* canvas designer panel */ -/*************************************************/ - -h4.panel-title { - cursor:pointer; - background-color: #f8f8f8; - color: #767676; -} - -.editor-category{ - margin: 5px 10px; - border: 1px solid #D9D9D9; -} - -.canvasdesigner-panel-container { - padding: 10px 10px; -} - -.canvasdesigner-panel-property { - clear: both; - overflow: hidden; - margin: 0 0 10px 0; -} - -.canvasdesigner .box-slider { - padding: 0px 0px 6px 0px; - overflow: hidden; - clear: both; -} - -.field-title { - float: left; - margin-right: 10px; - font-size: 12px; - color: #d9d9d9; -} - -.div-field { - margin-bottom: 10px; - overflow: hidden; - clear: both; -} - -/*************************************************/ -/* font family picker */ -/*************************************************/ - -.fontFamilyPickerPreview { - float: left; - width: 90%; - padding: 8px; - margin-top: 4px; - clear: both; - font-size: 18px; - color: #CDCDCD; - cursor: pointer; - border: 1px solid #CDCDCD; - text-align: center; position: relative; -} - -.fontFamilyPickerPreview span { - font-size: 32px; - line-height: 32px; -} - -.fontPickerDelete { - position: absolute; - margin: 5px 0 0 -15px; - cursor: pointer; - color:#CDCDCD; - right: 0; - top: 0; -} - -.fontFamilyPickerPreview:hover { - border: 1px solid #979797; - color:#979797; -} - -.fontFamilyPickerPreview:hover .fontPickerDelete { - color:#979797; -} - -.canvasdesigner-fontfamilypicker { - margin-bottom:30px; -} - -.canvasdesigner-fontfamilypicker select { - font-size: 12px; - padding: 2px; -} - -.canvasdesigner-fontfamilypicker select.font-list { - width:170px; -} - -.canvasdesigner-fontfamilypicker select.variant-list { - width:60px; -} - -.canvasdesigner-fontfamilypicker { - font-size: 42px; - line-height: 50px; - margin-bottom:40px; -} - -/*************************************************/ -/* slider */ -/*************************************************/ - -.canvasdesigner .ui-widget-content { - background: rgba(0, 0, 0, 0.27) !important; - border: 0 solid #fff !important; - border-radius: 1px !important; -} - -.canvasdesigner .ui-slider-horizontal { - margin: 14px 11px 0 11px; -} - -.ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default, -.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, -.ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { - border: 1px solid #535353 !important; - background: #535353 !important; - background-color:none !important; - outline:none; -} - -.canvasdesigner .ui-slider .ui-slider-handle:hover, -.canvasdesigner .ui-slider .ui-slider-handle:focus { - color: #d9d9d9 !important; -} - -.canvasdesigner .ui-slider .ui-slider-handle span { - position: absolute !important; - margin: -15px 0 0 -8px !important; - color: #535353 !important; - font-size: 9px !important; - text-align: -webkit-center !important; - display: block !important; - width: 30px !important; -} - -.canvasdesigner .slider-input { - float: right; - width: 25px; - padding: 0; - margin-top: -9px; - margin-right: 1px; - font-size: 12px; - color: #d9d9d9; - text-align: right; - background-color: transparent; - border: none; - -} - -@-moz-document url-prefix() { - .canvasdesigner .slider-input { - margin-top: -6px; - } -} - -.canvasdesigner .sp-replacer { - padding: 0; - margin: 0; - display: block; - border: none; - height: 26px; - border-radius: 1px; - border: 1px solid #CDCDCD; -} - -.canvasdesigner .sp-replacer:hover { - border: 1px solid #979797; -} - -.canvasdesigner .panel-body { - border-top: none !important; -} - -.canvasdesigner select { - font-size: 12px; -} - -.canvasdesigner .sp-dd { - display: none; -} - -.canvasdesigner .sp-preview { - width: 100%; - height: 100%; - margin-right: 0; - border: none; - display: block; -} - -.canvasdesigner .color-picker-preview { - height: 26px; - border: 1px solid #CDCDCD; - border-radius: 1px; - background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); - cursor: pointer; -} - -.canvasdesigner .float-panel .sp-container.sp-flat { - background-color: transparent; - border: none; - padding: 10px; - width: 100%; -} - -.canvasdesigner .float-panel .sp-container.sp-flat .sp-picker-container { - padding: 0; - margin: 0px; - margin-bottom: 10px; - border: none; - width: 100%; -} - -.canvasdesigner .float-panel .sp-container.sp-flat .sp-palette-container { - padding: 0; - height: 32px; - width: 100%; - float: left; - margin: 0; - border: none; -} - -.canvasdesigner .float-panel .sp-container.sp-flat .sp-button-container { - display: none; -} - -.colorPickerDelete { - position: absolute; - margin: -23px 0 0 0; - cursor: pointer; -} - -.borderStyleSelect -{ display: inline-block; - float: right; - width: 75px; - height: 28px; + + > .menu-bar__button { + position: relative; + } + + .dropdown-menu { + display:none; + + position: absolute; + right: 0; + bottom: 100%; + min-width: 200px; + + border-radius: 3px 3px 0 3px; + overflow: hidden; + + background-color: @blueExtraDark; + + > button { + position: relative; + display: list-item; + text-align: left; + width: 100%; + + &.--active { + &::before { + content: ''; + position: absolute; + left:0; + width: 3px; + top: 0; + bottom: 0; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + background-color: @pinkLight; + } + } + } + } + + &.--open { + z-index:1; + box-shadow: 0 5px 10px 0 rgba(0,0,0,.26); + > .menu-bar__button { + z-index: @zindexDropdown + 1; + } + .dropdown-menu { + display:block; + z-index: @zindexDropdown; + box-shadow: 0 5px 10px 0 rgba(0,0,0,.26); + } + } + } + + + /*************************************************/ /* IFrame size */ /*************************************************/ -.desktop { +#demo-iframe-wrapper { + transition: all 240ms cubic-bezier(0.165, 0.84, 0.44, 1); +} + +.fullsize { width: 100%; height: 100%; margin: 0 auto; overflow: hidden; } +.desktop { + width: 1920px; + height: 1080px; +} + .laptop { width: 1366px; height: 768px; @@ -743,13 +231,13 @@ h4.panel-title { height: 360px; } -.border { - margin: 75px auto; - background-color: #ffffff; - border-radius: 10px; +.shadow { + margin: 10px auto; + background-color: @white; + border-radius: 3px; + overflow: hidden; opacity: 1.0; - box-shadow: 0 0 0 29px #E9E9EB, 0 0 0 30px #D8D7D9; - transition: all 0.5s ease-in-out; + box-shadow: 0 5px 20px 0 rgba(0,0,0,.26); } iframe { @@ -767,251 +255,3 @@ iframe { .flip:before { transform: rotate(90deg); } - -/*************************************************/ -/* Image picker */ -/*************************************************/ - -.imagePickerPreview { - height: 20px; - text-align: center; - background-color: #fff; - padding: 6px 0 0 0; - cursor: pointer; - background-size: cover; - border-radius:1px; - border: 1px solid #CDCDCD; - color: #CDCDCD; -} - -.sp-clear-display { - background-image: none !important; -} - -.imagePickerPreview:hover { - border: 1px solid #979797; -} - -.imagePickerPreview:hover i { - color: #979797; -} - -.imagePickerPreview i { - font-size:24px; - color: #CDCDCD; -} - -.canvasdesignerImagePicker { - padding: 0; - margin-top: 10px; - overflow: auto; - text-align: left; - list-style: none; -} - -.canvasdesignerImagePicker ul { - padding: 0; - margin: 0; - margin-left: 20px; - list-style: none; -} - -.canvasdesignerImagePicker li { - display: inline-block; - margin-bottom: 10px; -} - -.canvasdesignerImagePicker ul.media-items li { - margin-right: 20px; -} - -.canvasdesignerImagePicker .media-preview { - position: relative; - display: inline-block; - width: 91px; - height: 78px; - cursor: pointer; - background-position: center center; - background-size: cover; - border: 2px solid #F3F2F2; -} - -.canvasdesignerImagePicker .media-preview .folder { - position: absolute; - top: 30px; - left: 0; - width: 100%; - font-size: 40px; - color: gainsboro; - text-align: center; -} - -.canvasdesignerImagePicker .media-preview .folder-name { - margin-top: -5px; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 13px; - line-height: 0; - color: grey; -} - -.canvasdesignerImagePicker .media-preview:hover, -.media-preview.selected { - border: 2px solid #84B8F0; -} - -.canvasdesignerImagePicker .modal-dialog { - width: 618px; - font-weight: normal; -} - -.bodyCanvasdesignerImagePicker .breadcrumb { - margin-top: 4px; - margin-bottom: 10px; - font-size: 16px; - text-align: left; -} - -.bodyCanvasdesignerImagePicker .breadcrumb > li { - display: inline; -} - -.breadcrumb{ - font-size: 12px; -} - -.bodyCanvasdesignerImagePicker .breadcrumb > li a { - cursor: pointer; -} - -.bodyCanvasdesignerImagePicker .breadcrumb input { - padding: 0; - margin: -4px -4px; -} - -.bodyCanvasdesignerImagePicker .fileinput-button { - position: absolute; - top: 10px; - right: 10px; - font-size: 19px; -} - -.bodyCanvasdesignerImagePicker input.input-media { - position: absolute; - top: 0; - right: 0; - margin: 0; - font-size: 23px; - cursor: pointer; - opacity: 0; - transform: translate(-300px, 0) scale(4); - direction: ltr; -} - -.bodyCanvasdesignerImagePicker .breadcrumb a.disabled, -.bodyCanvasdesignerImagePicker .breadcrumb a.disabled:hover { - color: grey; - text-decoration: none; - cursor: default; -} - -.canvasdesignerImagePicker h3 { - font-size: 18px; - color: #555555; -} - -/*************************************************/ -/* Border editor */ -/*************************************************/ - -.box-preview { - display:inline-block; -} - -.box-preview li { - height: 30px; - width: 30px; - border-radius: 1px; - border: none; - display:inline-block; - background-color:#535353; - cursor:pointer; - margin-right: 6px; - position:relative; -} - -.box-preview li:last-child{ - margin-right: 0px; -} - -.box-preview li.selected { - border-color:#53a93f !important; -} - -.box-preview li.border-all { - border: 6px solid #b3b3b3; - height: 18px; - width: 18px; - margin-left:0px -} - -.box-preview li.border-left { - border-left: 6px solid #b3b3b3; - width: 24px; -} - -.box-preview li.border-right { - border-right: 6px solid #b3b3b3; - width: 24px; -} - -.box-preview li.border-top { - border-top: 6px solid #b3b3b3; - height: 24px; -} - -.box-preview li.border-bottom { - border-bottom: 6px solid #b3b3b3; - height: 24px; -} - -.bordereditor .color-picker-preview { - display: inline-block; - width: 120px; - float: left; -} - -/*************************************************/ -/* Radius editor */ -/*************************************************/ - -.radius-top-left, .radius-top-right, .radius-bottom-left, .radius-bottom-right { - display: block; - position: absolute; - background-color: #b3b3b3; - width: 7px; - height: 7px; -} - -.box-preview li.selected span { - background-color:#53a93f; -} - -.radius-top-left{ - top:0; - left:0; -} - -.radius-top-right{ - top:0; - right:0; -} - -.radius-bottom-left{ - bottom:0; - left:0; -} - -.radius-bottom-right{ - bottom:0; - right:0 -} diff --git a/src/Umbraco.Web.UI.Client/src/less/colors.less b/src/Umbraco.Web.UI.Client/src/less/colors.less new file mode 100644 index 0000000000..4cb70cdf5e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/colors.less @@ -0,0 +1,81 @@ +.red{color: @red;} +.blue{color: @blue;} +.black{color: @black;} +.turquoise{color: @turquoise;} +.turquoise-d1{color: @turquoise-d1;} + +.text-warning { + color: @orange; +} +.text-error { + color: @red; +} +.text-success { + color: @green; +} + + +//icon colors for tree icons +.color-red, .color-red i{color: @red-d1 !important;} +.color-blue, .color-blue i{color: @turquoise-d1 !important;} +.color-orange, .color-orange i{color: @orange !important;} +.color-green, .color-green i{color: @green-d1 !important;} +.color-yellow, .color-yellow i{color: @yellowIcon !important;} + +/* Colors based on https://zavoloklom.github.io/material-design-color-palette/colors.html */ +.btn-color-black {background-color: @black;} +.color-black i { color: @black;} + +.btn-color-blue-grey {background-color: @blueGrey;} +.color-blue-grey, .color-blue-grey i { color: @blueGrey !important;} + +.btn-color-grey{background-color: @grayIcon;} +.color-grey, .color-grey i { color: @grayIcon !important; } + +.btn-color-brown{background-color: @brownIcon;} +.color-brown, .color-brown i { color: @brownIcon !important; } + +.btn-color-blue{background-color: @blueIcon;} +.color-blue, .color-blue i { color: @blueIcon !important; } + +.btn-color-light-blue{background-color: @lightBlueIcon;} +.color-light-blue, .color-light-blue i {color: @lightBlueIcon !important;} + +.btn-color-cyan{background-color: @cyanIcon;} +.color-cyan, .color-cyan i { color: @cyanIcon !important; } + +.btn-color-green{background-color: @greenIcon;} +.color-green, .color-green i { color: @greenIcon !important; } + +.btn-color-light-green{background-color: @lightGreenIcon;} +.color-light-green, .color-light-green i {color: @lightGreenIcon !important; } + +.btn-color-lime{background-color: @limeIcon;} +.color-lime, .color-lime i { color: @limeIcon !important; } + +.btn-color-yellow{background-color: @yellowIcon;} +.color-yellow, .color-yellow i { color: @yellowIcon !important; } + +.btn-color-amber{background-color: @amberIcon;} +.color-amber, .color-amber i { color: @amberIcon !important; } + +.btn-color-orange{background-color: @orangeIcon;} +.color-orange, .color-orange i { color: @orangeIcon !important; } + +.btn-color-deep-orange{background-color: @deepOrangeIcon;} +.color-deep-orange, .color-deep-orange i { color: @deepOrangeIcon !important; } + +.btn-color-red{background-color: @redIcon;} +.color-red, .color-red i { color: @redIcon !important; } + +.btn-color-pink{background-color: @pinkIcon;} +.color-pink, .color-pink i { color: @pinkIcon !important; } + +.btn-color-purple{background-color: @purpleIcon;} +.color-purple, .color-purple i { color: @purpleIcon !important; } + +.btn-color-deep-purple{background-color: @deepPurpleIcon;} +.color-deep-purple, .color-deep-purple i { color: @deepPurpleIcon !important; } + +.btn-color-indigo{background-color: @indigoIcon;} +.color-indigo, .color-indigo i { color: @indigoIcon !important; } \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-dashboard.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-dashboard.less index 17fdcc905e..52ff2c2b01 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-dashboard.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-dashboard.less @@ -12,7 +12,6 @@ flex: 0 0 @editorHeaderHeight; background: @white; border-bottom: 1px solid @gray-9; - padding: 20px 0 0 0; box-sizing: border-box; display: flex; justify-content: flex-end; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-drawer.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-drawer.less index 8c3b059a94..5c77a15ec7 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-drawer.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-drawer.less @@ -86,6 +86,7 @@ } .umb-help-badge__title { + display: block; font-size: 15px; font-weight: bold; color: @black; @@ -160,6 +161,9 @@ border-radius: 0; border-bottom: 1px solid @gray-9; padding: 10px; + background: transparent; + width:100%; + border: 0 none; } .umb-help-list-item:last-child { @@ -194,7 +198,6 @@ .umb-help-list-item__title { font-size: 14px; display: block; - margin-left: 26px; } .umb-help-list-item__description { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-language-picker.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-language-picker.less index 527c742382..4e3741905f 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-language-picker.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-language-picker.less @@ -12,11 +12,14 @@ align-items: center; justify-content: space-between; padding: 0 20px; - cursor: pointer; + background: transparent; + border: 0 none; border-bottom: 1px solid @gray-9; height: @editorHeaderHeight; box-sizing: border-box; color: @ui-option-type; + width: 100%; + outline-offset: -3px; } .umb-language-picker__expand { @@ -43,20 +46,24 @@ overflow: auto; } -.umb-language-picker__dropdown a { +.umb-language-picker__dropdown-item { + background: transparent; + border: 0 none; padding: 8px 20px; display: block; font-size: 14px; + width: 100%; + text-align: left; + outline-offset: -3px; } -.umb-language-picker__dropdown a:hover, -.umb-language-picker__dropdown a:focus { +.umb-language-picker__dropdown-item:hover { background: @ui-option-hover; text-decoration: none; color:@ui-option-type-hover; } -.umb-language-picker__dropdown a.umb-language-picker__dropdown-item--current { +.umb-language-picker__dropdown .umb-language-picker__dropdown-item.umb-language-picker__dropdown-item--current { padding-left: 16px; border-left: 4px solid @ui-light-active-border; color:@ui-light-active-type; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-search.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-search.less index 70e4f3d372..2f9430ef41 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-search.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-search.less @@ -16,6 +16,10 @@ box-shadow: 0 10px 20px rgba(0,0,0,.12),0 6px 6px rgba(0,0,0,.14); } +.umb-search__label{ + margin: 0; +} + /* Search field */ @@ -107,4 +111,4 @@ .umb-search-result__description { color: @gray-5; font-size: 13px; -} \ No newline at end of file +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-tour.less b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-tour.less index 315cd91dbd..bf2f030cea 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/application/umb-tour.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/application/umb-tour.less @@ -1,8 +1,12 @@ -.umb-tour__loader { - background: @white; - z-index: @zindexTourModal; +.umb-loader-wrapper.umb-tour__loader { + margin: 0; position: fixed; - height: 5px; + z-index: @zindexTourModal; + + .umb-loader { + background-color: @white; + height: 5px; + } } .umb-tour__pulse { @@ -108,3 +112,24 @@ .umb-tour-is-visible .umb-backdrop { z-index: @zindexTourBackdrop; } + +.umb-tour__popover .underline{ + font-size: 13px; + background: transparent; + border: none; + padding: 0; +} + +.umb-tour__popover--promotion { + width: 800px; + min-height: 400px; + padding: 40px; + border-radius: @baseBorderRadius * 2; + .umb-tour-step__close { + top: 40px; + right: 40px; + } + a { + text-decoration: underline; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-group.less b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-group.less index e40282cb58..0465881387 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-group.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button-group.less @@ -9,6 +9,7 @@ left: auto; } +.umb-button-group__sub-buttons>li>button, .umb-button-group__sub-buttons>li>a { display: flex; } @@ -20,7 +21,7 @@ } .umb-button-group__toggle { - border-radius: 0px @baseBorderRadius @baseBorderRadius 0; + border-radius: 0 @baseBorderRadius @baseBorderRadius 0; border-left: 1px solid rgba(0,0,0,0.09); margin-left: -2px; padding-left: 10px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button.less b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button.less index 7fc965a8fa..4127c2201c 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-button.less @@ -8,21 +8,6 @@ position: relative; } -.umb-button__button:focus { - outline: none; - .tabbing-active &:after { - content: ''; - position: absolute; - z-index: 10000; - top: 0; - bottom: 0; - left: 0; - right: 0; - border-radius: 3px; - box-shadow: 0 0 2px @blueMid, inset 0 0 2px 1px @blueMid; - } -} - .umb-button__content { opacity: 1; transition: opacity 0.25s ease; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-toggle.less b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-toggle.less index a621370d02..456601a7bd 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-toggle.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/buttons/umb-toggle.less @@ -57,7 +57,7 @@ .umb-toggle.umb-toggle--checked & { transform: translateX(20px); - background-color: white; + background-color: @white; } } @@ -75,7 +75,7 @@ .umb-toggle__icon--left { left: 5px; - color: white; + color:@white; transition: opacity 120ms; opacity: 0; // .umb-toggle:hover & { @@ -85,7 +85,7 @@ opacity: 1; } .umb-toggle.umb-toggle--checked:hover & { - color: white; + color:@white; } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/card.less b/src/Umbraco.Web.UI.Client/src/less/components/card.less index 8324698685..ed80359833 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/card.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/card.less @@ -5,7 +5,7 @@ .umb-card{ position: relative; padding: 5px 10px 5px 10px; - background: white; + background: @white; width: 100%; .title{padding: 12px; color: @gray-3; border-bottom: 1px solid @gray-8; font-weight: 400; font-size: 16px; text-transform: none; margin: 0 -10px 10px -10px;} @@ -86,7 +86,7 @@ margin: 0 auto; list-style: none; width: 100%; - + display: flex; flex-flow: row wrap; justify-content: flex-start; @@ -119,7 +119,7 @@ padding-top: 100%; border-radius: 3px; transition: background-color 120ms; - + > span { position: absolute; top: 10px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/contextdialogs/umb-dialog-datatype-delete.less b/src/Umbraco.Web.UI.Client/src/less/components/contextdialogs/umb-dialog-datatype-delete.less new file mode 100644 index 0000000000..0e0b8f22bd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/contextdialogs/umb-dialog-datatype-delete.less @@ -0,0 +1,33 @@ +.umb-dialog-datatype-delete { + + + .umb-dialog-datatype-delete__table-head-column-name { + width: 140px; + } + + .umb-table-body__icon { + margin-right: 5px; + vertical-align: top; + display: inline-block; + } + + .table tbody td { + vertical-align: top; + } + .table tbody td > span { + margin: 5px 0; + vertical-align: middle; + } + .table tbody p { + line-height: 12px; + margin: 5px 0; + vertical-align: middle; + } + + .table tbody .icon { + vertical-align: top; + margin-right: 5px; + display: inline-block; + } + +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/editor.less b/src/Umbraco.Web.UI.Client/src/less/components/editor.less index 52dd7ea678..bc84b0d35e 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/editor.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/editor.less @@ -74,8 +74,11 @@ } .umb-editor-header__back { - color: @gray-6; - margin-bottom: 1px; + background: transparent; + border: 0; + color: @gray-6; + margin: 0 0 1px 0; + padding: 0; transition: color 0.1s ease-in-out; } @@ -161,6 +164,7 @@ a.umb-editor-header__close-split-view:hover { /* variant switcher */ .umb-variant-switcher__toggle { + position: relative; display: flex; align-items: center; padding: 0 10px; @@ -170,6 +174,8 @@ a.umb-editor-header__close-split-view:hover { text-decoration: none !important; font-size: 13px; color: @ui-action-discreet-type; + background: transparent; + border: none; max-width: 50%; white-space: nowrap; @@ -182,7 +188,7 @@ a.umb-editor-header__close-split-view:hover { } } -a.umb-variant-switcher__toggle { +button.umb-variant-switcher__toggle { transition: color 0.2s ease-in-out; &:hover { //background-color: @gray-10; @@ -239,8 +245,7 @@ a.umb-variant-switcher__toggle { border-left: 4px solid @ui-active; } -.umb-variant-switcher__item:hover, -.umb-variant-switcher__item:focus { +.umb-variant-switcher__item:hover { outline: none; } @@ -264,7 +269,7 @@ a.umb-variant-switcher__toggle { align-items: center; justify-content: center; margin-left: 5px; - top: -6px; + top: -3px; width: 14px; height: 14px; border-radius: 7px; @@ -282,8 +287,10 @@ a.umb-variant-switcher__toggle { flex: 1; cursor: pointer; padding-top: 6px !important; - padding-bottom: 6px !important; - border-left: 2px solid transparent; + padding-bottom: 6px !important; + background-color: transparent; + border: none; + border-left: 2px solid transparent; } .umb-variant-switcher__name { @@ -373,14 +380,3 @@ a.umb-variant-switcher__toggle { margin-right: auto; padding-right: 10px; } - -/* Confirm */ -.umb-editor-confirm { - background-color: @white; - padding: 20px; - position: absolute; - left: 0; - bottom: 0; - z-index: 10; - box-shadow: 0 -3px 12px 0px rgba(0,0,0,0.16); -} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/editor/subheader/umb-editor-sub-header.less b/src/Umbraco.Web.UI.Client/src/less/components/editor/subheader/umb-editor-sub-header.less index 6cf3598638..4ebfa94b6f 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/editor/subheader/umb-editor-sub-header.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/editor/subheader/umb-editor-sub-header.less @@ -1,14 +1,11 @@ .umb-editor-sub-header { padding: 10px 0; - margin-bottom: 10px; background: @brownGrayLight; border-left: 5px solid @brownGrayLight; border-right: 5px solid @brownGrayLight; - margin-left: -5px; - margin-right: -5px; display: flex; justify-content: space-between; - margin-top: -10px; + margin: -10px -5px 10px; position: relative; top: 0; box-sizing: border-box; @@ -20,44 +17,40 @@ } } .umb-editor-sub-header--white { - background-color: white; - border-color: white; + background-color: @white; + border-color: @white; } .umb-editor-sub-header.--state-selection { padding-left: 10px; padding-right: 10px; - background-color: @pinkLight; - border-color: @pinkLight; + background-color: @ui-selected-border; + border-color: @ui-selected-border; + color: @white; border-radius: 3px; } [umb-sticky-bar] { transition: box-shadow 240ms; - margin-top: 0; - margin-bottom: 0; position:sticky; - z-index: 99; - + z-index: 30; + &.umb-sticky-bar--active { box-shadow: 0 6px 3px -3px rgba(0,0,0,.16); } + + .umb-dashboard__content & { + top:-20px; // umb-dashboard__content has 20px padding - offset here prevents sticky position from firing when page loads + } } .umb-sticky-sentinel { - position: absolute; - left: 0; - width: 100%; pointer-events: none; - + z-index: 5050; + &.-top { - top:0px; height:1px; - } - - &.-bottom { - bottom:50px; - height:10px; + transform:translateY(-10px); } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/editor/umb-editor.less b/src/Umbraco.Web.UI.Client/src/less/components/editor/umb-editor.less index a9d879ab7f..d2a3bdedb1 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/editor/umb-editor.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/editor/umb-editor.less @@ -1,82 +1,86 @@ .umb-editors { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; + .absolute(); overflow: hidden; + + .umb-editor { + box-shadow: 0px 0 30px 0 rgba(0,0,0,.3); + } } .umb-editor { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; + .absolute(); background: @brownGrayLight; z-index: @zIndexEditor; -} -.umb-editor--infinityMode { - transform: none; - will-change: transform; - transition: transform 400ms ease-in-out; - &.moveRight { - transform: translateX(110%); + &--infiniteMode { + transform: none; + will-change: transform; + transition: transform 400ms ease-in-out; + + &.umb-editor--moveRight { + transform: translateX(110%); + } + } + + &--outOfRange { + transform: none; + display: none; + will-change: auto; + transition: display 0s 320ms; + } + + &--level0 { + transform: none; } } -.umb-editor--outOfRange { - transform: none; - display: none; - will-change: auto; - transition: display 0s 320ms; -} -.umb-editor--level0 { - transform: none; -} -.umb-editor--level1 { - transform: translateX(60px); -} -.umb-editor--level2 { - transform: translateX(120px); -} -.umb-editor--level3 { - transform: translateX(180px); -} +// use a loop to build the editor levels +@iterations: 3; +@step: 60px; -.umb-editor--n1 { - right:60px; -} -.umb-editor--n2 { - right:120px; -} -.umb-editor--n3 { - right:180px; -} - -// hide all infinite editors by default -// will be shown through animation -.umb-editors .umb-editor { - box-shadow: 0px 0 30px 0 rgba(0,0,0,.3); -} - -.umb-editor--small { - width: 500px; - will-change: transform; - left: auto; - - .umb-editor-container { - max-width: 500px; +.level-loop (@i) when (@i > 0) { + @x: @i * @step; + .umb-editor--level@{i} { + transform: translateX(@x); } + + .umb-editor--n@{i} { + right:@x; + } + + .level-loop(@i - 1); } +.level-loop(@iterations); + +// and also use a loop to build editor sizes - easily extended with new sizes by adding to the map +@editorSizes: + small 500px, + medium 800px; + +.create-editor-sizes(@iterator:1) when(@iterator <= length(@editorSizes)) { + .umb-editor { + @size: extract(extract(@editorSizes, @iterator), 1); + @value: extract(extract(@editorSizes, @iterator), 2); + + &--@{size} { + width: @value; + will-change: transform; + left: auto; + + .umb-editor--container { + max-width: @value; + } + } + } + + .create-editor-sizes(@iterator + 1); +} + +.create-editor-sizes(); + .umb-editor__overlay { - position: absolute; - top: 0; - bottom: 0; - right: 0; - left: 0; + .absolute(); background: rgba(0,0,0,0.4); z-index: @zIndexEditor; visibility: hidden; @@ -85,8 +89,19 @@ } #contentcolumn > .umb-editor__overlay, -.--notInFront .umb-editor__overlay { +.umb-editor--notInFront .umb-editor__overlay { visibility: visible; opacity: 1; transition: opacity 320ms 20ms, visibility 0s; } + +.umb-editor--trashed-message { + background:@errorBackground; + color:@errorText; + padding:10px; + margin-bottom:20px; + + i { + margin-right:5px; + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/components/notifications/umb-notifications.less b/src/Umbraco.Web.UI.Client/src/less/components/notifications/umb-notifications.less index 7f04fef9a9..67038380ca 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/notifications/umb-notifications.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/notifications/umb-notifications.less @@ -1,5 +1,5 @@ .umb-notifications { - z-index: 1000; + z-index: 1100; position: absolute; bottom: @editorFooterHeight; left: 0; @@ -14,7 +14,7 @@ list-style: none; margin: 0; position: relative; -} +} .umb-notifications__notification { padding: 5px 20px; @@ -24,11 +24,14 @@ position: relative; border-radius: 10px; margin: 10px; - + .close { + position: absolute; top: 0; - right: -6px; + bottom: 0; + right: 6px; opacity: 0.4; + margin: auto 0; } } @@ -36,3 +39,8 @@ padding-top: 20px; padding-bottom: 20px; } + + +.emptySection .umb-notifications{ + left:0; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/overlays.less b/src/Umbraco.Web.UI.Client/src/less/components/overlays.less index c8f0195ea5..eb8740b385 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/overlays.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/overlays.less @@ -6,6 +6,10 @@ animation: fadeIn 0.2s; box-shadow: 0 10px 50px rgba(0,0,0,0.1), 0 6px 20px rgba(0,0,0,0.16); text-align: left; + + .scoped-view{ + display: none; + } } .umb-overlay__form { @@ -16,23 +20,22 @@ } .umb-overlay .umb-overlay-header { - border-bottom: 1px solid @purple-l3; + border-bottom: 1px solid @gray-9; margin-top: 0; flex-grow: 0; flex-shrink: 0; - - padding: 20px 30px 0; + padding: 20px 20px 0; } .umb-overlay__section-header { width: 100%; margin-top:30px; margin-bottom: 10px; - + h5 { display: inline; } - + button { display: inline; float: right; @@ -45,10 +48,11 @@ } .umb-overlay__title { - font-size: @fontSizeLarge; + font-size: 16px; color: @black; + line-height: 16px; font-weight: bold; - margin: 7px 0; + margin: 5px 0; } .umb-overlay__subtitle { @@ -62,9 +66,8 @@ flex-shrink: 1; flex-basis: auto; position: relative; - - padding: 0px 30px; - margin-bottom: 10px; + padding: 30px; + background: @white; max-height: calc(100vh - 170px); overflow-y: auto; } @@ -72,12 +75,11 @@ .umb-overlay-drawer { flex-grow: 0; flex-shrink: 0; - flex-basis: 31px; - padding: 10px 20px; + flex-basis: 33px; + padding: 8px 20px; margin: 0; - - background: @gray-10; - border-top: 1px solid @purple-l3; + background: @white; + border-top: 1px solid @gray-9; } .umb-overlay-drawer.-auto-height { @@ -124,11 +126,7 @@ max-height: 100vh; box-sizing: border-box; border-radius: @baseBorderRadius; - /* default: - &.umb-overlay--small { - width: 400px; - } - */ + &.umb-overlay--medium { width: 480px; } @@ -142,7 +140,6 @@ .umb-overlay.umb-overlay-target .umb-overlay-drawer { border: none; background: transparent; - padding: 0 30px 20px; } /* ---------- OVERLAY RIGHT ---------- */ @@ -153,10 +150,14 @@ bottom: 0; border: none; box-shadow: 0 0 20px rgba(0,0,0,0.19), 0 0 6px rgba(0,0,0,0.23); + + .umb-drawer-is-visible & { + right:400px; + } } .umb-overlay.umb-overlay-right .umb-overlay-header { - flex-basis: 100px; + flex-basis: 70px; box-sizing: border-box; } @@ -228,12 +229,12 @@ } .umb-overlay__item-details-title { - margin-top: 0; - margin-bottom: 0; + margin: 0; + font-size: 15px; } .umb-overlay__item-details-description { - margin-top: 10px; + margin: 10px 0 0; font-size: 12px; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/prevalues/multivalues.less b/src/Umbraco.Web.UI.Client/src/less/components/prevalues/multivalues.less index 9947c793c2..7036d60a63 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/prevalues/multivalues.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/prevalues/multivalues.less @@ -44,7 +44,7 @@ display: flex; padding: 6px; margin: 10px 0px !important; - background: #F3F3F5; + background: @gray-10; cursor: move; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-actions.less b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-actions.less index d6e792de73..0c231830de 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-actions.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-actions.less @@ -94,6 +94,7 @@ font-size: 14px; color: @black; margin-left: 10px; + text-align: left; } small { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree-item.less b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree-item.less index 8f0b55f9ed..4a483ce3f0 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree-item.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree-item.less @@ -3,17 +3,17 @@ min-width: 100%; width: auto; margin-top:1px; - - .umb-tree-item__label { - user-select: none; - } - + &:hover .umb-tree-item__arrow { visibility: visible; cursor: pointer } } +.umb-tree-item__label { + user-select: none; +} + .umb-tree-item__arrow { position: relative; margin-left: -16px; @@ -36,40 +36,34 @@ overflow: hidden; margin-right: 6px; } - + // Loading Animation // ------------------------ - .l { + .umb-tree-item__loader { width: 100%; - height: 2px; - overflow: hidden; position: absolute; - left: 0; - bottom: -1px; - - div { - .umb-loader; - } + margin: 0; } .umb-tree-item__label { - padding: 7px 0 5px; + padding: 7px 0 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 1 0 auto; + flex: 1 1 auto; } } // active is equivilant to selected, its the item that is begin affected by the actions performed in the right-click-dialog. .umb-tree-item.active > .umb-tree-item__inner { + border-color: @ui-selected-border; + box-shadow: 0 0 2px 0 fade(@ui-selected-border, 80%); color: @ui-selected-type; + a { color: @ui-selected-type; } - - border-color: @ui-selected-border; - box-shadow: 0 0 2px 0 fade(@ui-selected-border, 80%); + &::before { content: ""; position: absolute; @@ -77,10 +71,12 @@ left: 0; right: 0; bottom: 0; - border: 2px solid fade(white, 80%); + border: 2px solid fade(@white, 80%); } + &:hover { color: @ui-selected-type-hover; + a { color: @ui-selected-type-hover; } @@ -88,26 +84,13 @@ } .umb-tree-item.current > .umb-tree-item__inner { - background: @ui-active; color:@ui-active-type; - - // override small icon color. TODO => check usage + + // override small icon color. TODO => check usage &:before { color: @blue; } - - .umb-options { - - &:hover i { - opacity: .7; - } - - i { - background: @ui-active-type; - transition: opacity 120ms ease; - } - } a, .umb-tree-icon, @@ -118,5 +101,5 @@ .umb-tree-item.current-not-active > .umb-tree-item__inner { background: @ui-active-blur; - color:@ui-active-type; + color:@ui-active-type; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less index 5c54232200..d06c15cd30 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less @@ -33,12 +33,6 @@ display: none; } } - - //loader defaults - .umb-loader { - height: 10px; - margin: 10px 10px 10px 10px; - } .search-subtitle { color: @gray-7; @@ -72,9 +66,7 @@ body.touch .umb-tree { overflow: hidden; display: flex; flex-wrap: nowrap; - align-items: center; - - border:2px solid transparent; + align-items: center; color: @ui-option-type; a { @@ -105,6 +97,11 @@ body.touch .umb-tree { } } +.umb-tree-item__inner { + border: 2px solid transparent; + overflow: visible; +} + .umb-tree-header { display: flex; padding: 20px 0 20px 20px; @@ -180,10 +177,25 @@ body.touch .umb-tree { cursor: pointer; border-radius: @baseBorderRadius; - &:hover { - background: @btnBackgroundHighlight; + i { + height: 5px !important; + width: 5px !important; + border-radius: 20px; + display: inline-block; + margin: 0 2px 0 0; + background: @ui-active-type; + + &:last-child { + margin: 0; + } } - + &:hover { + background: rgba(255, 255, 255, .5); + i { + background: @ui-active-type-hover; + } + } + // NOTE - We're having to repeat ourselves here due to an .sr-only class appearing in umbraco/lib/font-awesome/css/font-awesome.min.css &.sr-only--hoverable:hover, &.sr-only--focusable:focus { @@ -198,19 +210,6 @@ body.touch .umb-tree { border-radius: 3px; } - i { - height: 5px !important; - width: 5px !important; - border-radius: 20px; - background: @black; - display: inline-block; - margin: 0 2px 0 0; - - &:last-child { - margin: 0; - } - } - .hide-options & { display: none !important; } @@ -294,9 +293,8 @@ body.touch .umb-tree { } .no-access { - .umb-tree-icon, - .root-link, - .umb-tree-item__label { + > .umb-tree-item__inner .umb-tree-icon, + > .umb-tree-item__inner .umb-tree-item__label { color: @gray-7; cursor: not-allowed; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-avatar.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-avatar.less index 21f4a7bda8..c6b9dc7261 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-avatar.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-avatar.less @@ -103,7 +103,7 @@ a.umb-avatar-btn:hover { text-decoration: none; } a.umb-avatar-btn .umb-avatar { - border: 2px dashed #A2A1A6; + border: 2px dashed @gray-6; } a.umb-avatar-btn .umb-avatar span { font-size: 50px; @@ -114,4 +114,4 @@ a.umb-avatar-btn .umb-avatar span { font-size: 50px; } -/*border-radius: 50%; width: 100px; height: 100px; font-size: 50px; text-align: center; display: flex; align-items: center; justify-content: center; background-color: #F3F3F5; border: 2px dashed #A2A1A6; color: #A2A1A6;*/ +/*border-radius: 50%; width: 100px; height: 100px; font-size: 50px; text-align: center; display: flex; align-items: center; justify-content: center; background-color: @gray-10; border: 2px dashed @gray-6; color: @gray-6;*/ diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-box.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-box.less index c0e91e28c2..1fe59ab9dd 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-box.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-box.less @@ -1,12 +1,14 @@ +@boxUnit: 10px; + .umb-box { background: @white; border-radius: 3px; - margin-bottom: 20px; + margin-bottom: @boxUnit * 2; box-shadow: 0 1px 1px 0 rgba(0,0,0,.16); } .umb-box-header { - padding: 10px 20px; + padding: @boxUnit @boxUnit * 2; border-bottom: 1px solid @gray-9; display: flex; align-items: center; @@ -27,5 +29,19 @@ } .umb-box-content { - padding: 20px; + padding: @boxUnit * 2; } + +// allow side-by-side boxes +.umb-box-row { + margin-left:-@boxUnit; + margin-right:-@boxUnit; + display:flex; + justify-content: space-around; + + .umb-box { + margin-left:@boxUnit; + margin-right:@boxUnit; + flex:1; + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-breadcrumbs.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-breadcrumbs.less index ba04069c6b..de678f9798 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-breadcrumbs.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-breadcrumbs.less @@ -4,10 +4,32 @@ margin-left: 0; display: flex; flex-wrap: wrap; + user-select: none; } .umb-breadcrumbs__ancestor { display: flex; + align-items: center; +} + +.umb-breadcrumbs__action { + position: relative; + background: transparent; + border: 0 none; + border-radius: 3px; + padding: 0 4px; + color: @ui-option-type; + + &.--current { + font-weight: bold; + pointer-events: none; + } + + &:hover { + color: @ui-option-type-hover; + background-color: @white; + } + } .umb-breadcrumbs__ancestor-link, @@ -18,6 +40,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + padding: 0 4px; } .umb-breadcrumbs__ancestor-link { @@ -31,14 +54,13 @@ .umb-breadcrumbs__separator { position: relative; top: 1px; - margin-left: 5px; - margin-right: 5px; + margin: 0 1px; + margin-top: -3px; color: @gray-7; } input.umb-breadcrumbs__add-ancestor { - height: 25px; - margin-top: -2px; - margin-left: 3px; + height: 24px; + margin: -2px 0 -2px 3px; width: 100px; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-checkbox-list.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-checkbox-list.less index 985e57bea5..11194eeb43 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-checkbox-list.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-checkbox-list.less @@ -4,6 +4,7 @@ .umb-checkbox-list { list-style: none; margin-left: 0; + margin-top: 6px; } .umb-checkbox-list__item { @@ -12,6 +13,10 @@ margin-bottom: 2px; } +.umb-checkbox-list li:first-child { + font-weight: bold; +} + .umb-checkbox-list__item:last-child { border-bottom: none; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-checkmark.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-checkmark.less index 021fc8cc9b..f82e47bf88 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-checkmark.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-checkmark.less @@ -2,21 +2,30 @@ border: 2px solid @white; width: 25px; height: 25px; - border: 1px solid @gray-7; + border: 1px solid @ui-action-discreet-border; border-radius: 3px; box-sizing: border-box; display: flex; justify-content: center; align-items: center; - color: @gray-7; + color: @ui-selected-type; cursor: pointer; font-size: 15px; + &:hover { + border-color:@ui-action-discreet-border-hover; + color: @ui-selected-type-hover; + } } .umb-checkmark--checked { - background: @ui-active; - border-color: @ui-active; + background: @ui-selected-border; + border-color: @ui-selected-border; color: @white; + &:hover { + background: @ui-selected-border-hover; + border-color: @ui-selected-border-hover; + color: @white; + } } .umb-checkmark--xs { @@ -45,4 +54,4 @@ width: 50px; height: 50px; font-size: 20px; -} \ No newline at end of file +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-child-selector.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-child-selector.less index b6cdc0e8d9..da690663d0 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-child-selector.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-child-selector.less @@ -30,6 +30,9 @@ .umb-child-selector__children-container { margin-left: 30px; + .umb-child-selector__child { + cursor: move; + } } .umb-child-selector__child-description { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-code-snippet.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-code-snippet.less new file mode 100644 index 0000000000..b372841910 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-code-snippet.less @@ -0,0 +1,43 @@ +.umb-code-snippet { + + .umb-code-snippet__header { + box-sizing: content-box; + background-color: @gray-10; + display: flex; + flex-direction: row; + font-size: .8rem; + border: 1px solid @gray-8; + border-radius: 3px 3px 0 0; + border-bottom: 0; + margin-top: 16px; + min-height: 30px; + + .language { + display: flex; + align-items: center; + justify-content: flex-start; + flex-grow: 1; + padding: 2px 10px; + } + + button { + background-color: transparent; + border: none; + border-left: 1px solid @gray-8; + border-radius: 0; + color: #000; + + &:hover { + background-color: @grayLighter; + } + } + } + + .umb-code-snippet__content { + pre { + border-radius: 0 0 3px 3px; + overflow: auto; + white-space: nowrap; + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-content-grid.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-content-grid.less index 84cfe04263..622dcb8b0a 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-content-grid.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-content-grid.less @@ -29,15 +29,15 @@ border-radius: 5px; box-shadow: 0 0 4px 0 darken(@ui-selected-border, 20), inset 0 0 2px 0 darken(@ui-selected-border, 20); pointer-events: none; + transition: opacity 100ms; } - } } .umb-content-grid__item:hover { &::before { - opacity: .33; + opacity: .2; } } .umb-content-grid__item.-selected:hover { @@ -46,6 +46,7 @@ } } + .umb-content-grid__icon-container { height: 75px; display: flex; @@ -66,8 +67,10 @@ } .umb-content-grid__item-name { + position: relative; + padding: 5px; + margin: -5px -5px 15px -5px; font-weight: bold; - margin-bottom: 15px; line-height: 1.4em; display: inline-flex; @@ -105,6 +108,7 @@ .umb-content-grid__details-value { display: inline-block; word-break: break-word; + margin-left: 3px; } .umb-content-grid__checkmark { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-contextmenu.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-contextmenu.less new file mode 100644 index 0000000000..8512e2020d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-contextmenu.less @@ -0,0 +1,75 @@ +.umb-contextmenu { + margin: 0; + list-style: none; + user-select: none; + + overflow: hidden; + border-radius: 3px; + border: 1px solid @dropdownBorder; + .box-shadow(0 5px 20px rgba(0,0,0,.3)); + border-bottom: 1px solid rgba(0,0,0,.2); + + .sep { + display: block; + border-top: 1px solid @gray-9; + + &:first-child { + border-top: none; + } + } + +} + +.umb-contextmenu-item { + + .icon { + font-size: 18px; + vertical-align: middle; + } + + .menu-label { + display: inline-block; + vertical-align: middle; + margin-left: 5px; + } + + button { + + position: relative; + + display: block; + font-weight: normal; + line-height: @baseLineHeight; + white-space: nowrap; + + background-color: @ui-option; + border: 0; + padding: 7px 12px; + color: @ui-option-type; + width: 100%; + + font-size: 14px; + text-align: left; + + &:hover { + text-decoration: none; + color: @ui-option-type-hover; + background-color: @ui-option-hover; + } + } + + &.-opens-dialog { + .menu-label:after { + // adds an ellipsis (...) after the menu label for actions that open a dialog + content: '\2026'; + } + } + button:disabled { + cursor: not-allowed; + color: @ui-option-disabled-type; + &:hover { + color: @ui-option-disabled-type-hover; + background-color: @ui-option; + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-flatpickr.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-date-time-picker.less similarity index 82% rename from src/Umbraco.Web.UI.Client/src/less/components/umb-flatpickr.less rename to src/Umbraco.Web.UI.Client/src/less/components/umb-date-time-picker.less index 8cdcc8b877..5bcfdd1c71 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-flatpickr.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-date-time-picker.less @@ -1,4 +1,4 @@ -.flatpickr-calendar.flatpickr-calendar { +.flatpickr-calendar { border-radius: @baseBorderRadius; box-shadow: 0 5px 10px 0 rgba(0,0,0,0.16); } @@ -6,6 +6,10 @@ span.flatpickr-day { border-radius: @baseBorderRadius; border: none; + + &.today:not(.active) { + border: 1px solid; + } } span.flatpickr-day:hover { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-editor-navigation-item.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-editor-navigation-item.less index e6b3fdbfa9..6a6a8f9f5b 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-editor-navigation-item.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-editor-navigation-item.less @@ -1,172 +1,188 @@ .umb-sub-views-nav-item { position: relative; display: block; -} -.umb-sub-views-nav-item > a { - text-align: center; - cursor: pointer; - display: block; - padding: 4px 10px 0 10px; - min-width: 70px; - border-right: 1px solid @gray-9; - box-sizing: border-box; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: calc(~'@{editorHeaderHeight}' - ~'1px'); // need to offset the 1px border-bottom on .umb-editor-header - avoids overflowing top of the container - position: relative; - - color: @ui-active-type; - - &:hover { - color: @ui-active-type-hover !important; - } - - &::after { - content: ""; - height: 0px; - left: 8px; - right: 8px; - background-color: @ui-light-active-border; - position: absolute; - bottom: 0; - border-radius: 3px 3px 0 0; - opacity: 0; - transition: all .2s linear; - } -} -.umb-sub-views-nav-item > a:active { - .box-shadow(~"inset 0 2px 4px rgba(0,0,0,.15), 0 1px 2px rgba(0,0,0,.05)"); -} -.umb-sub-views-nav-item > a:focus { - outline: none; -} + &__action, + > a { + position: relative; + background: transparent; + text-align: center; + cursor: pointer; + display: block; + padding: 4px 10px 0 10px; + min-width: 70px; + border: 0 none; + border-right: 1px solid @gray-9; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: calc(~'@{editorHeaderHeight}'- ~'1px'); // need to offset the 1px border-bottom on .umb-editor-header - avoids overflowing top of the container + color: @ui-active-type; -.umb-sub-views-nav-item > a:hover, -.umb-sub-views-nav-item > a:focus { - text-decoration: none; -} + &:hover { + color: @ui-active-type-hover !important; + text-decoration: none; + } -.umb-sub-views-nav-item > a.is-active { - - color: @ui-light-active-type; - - &::after { + &::before { + content: ""; + position: absolute; + height: 0px; + left: 8px; + right: 8px; + background-color: @ui-light-active-border; + bottom: 0; + border-radius: 3px 3px 0 0; + opacity: 0; + transition: all .2s linear; + } + + &.is-active { + color: @ui-light-active-type; + + &::before { + opacity: 1; + height: 4px; + } + } + } + + &__action:active, + & > a:active { + .box-shadow(~"inset 0 2px 4px rgba(0,0,0,.15), 0 1px 2px rgba(0,0,0,.05)"); + } + + &:focus-within &__anchor_dropdown, + &:hover &__anchor_dropdown { + visibility: visible; opacity: 1; - height: 4px; + transition: opacity 20ms 0; + } + + .icon { + font-size: 24px; + display: block; + text-align: center; + margin-bottom: 7px; + } + + .badge { + position: absolute; + top: 6px; + right: 6px; + min-width: 16px; + color: @white; + background-color: @ui-active-type; + border: 2px solid @white; + border-radius: 50%; + font-size: 10px; + font-weight: bold; + padding: 2px; + line-height: 16px; + display: block; + + &.-type-alert { + background-color: @red; + } + + &.-type-warning { + background-color: @yellow-d2; + } + + &:empty { + height: 12px; + min-width: 12px; + } + } + + &-text { + font-size: 12px; + line-height: 1em; + } + + &__anchor_dropdown { + // inherits from .dropdown-menu + margin: 0; + + // center align horizontal + left: 50%; + transform: translateX(-50%); + opacity: 0; + transition: opacity 250ms 250ms; + visibility: hidden; + + li { + &.is-active a { + border-left-color: @ui-active; + } + + a { + border-left: 4px solid transparent; + } + } + } + + // Currently Edge 18 does unfortunately not support :focus-within so for now we will use the "old" behavior - Support is coming with the upcoming release of Edge 76 + // See https://caniuse.com/#search=focus-within + @supports (-ms-ime-align:auto) { + &:hover &__anchor_dropdown { + transition: visibility 0 0, opacity 20ms 0; + } + + &__anchor_dropdown { + visibility: hidden; + transition: visibility 0 500ms, opacity 250ms 250ms; + } + } + + // -------------------------------- + // item__more, appears when there is not enough room for the visible items. + // -------------------------------- + + &-more__icon { + margin-bottom: 10px; + + i { + height: 5px; + width: 5px; + border-radius: 50%; + background: @ui-active-type; // fallback if browser doesnt support currentColor + background: currentColor; + display: inline-block; + margin: 0 5px 0 0; + } + + i:last-of-type { + margin-right: 0; + } + } + + &-more__dropdown { + left: auto; + right: 0; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + min-width: auto; + margin-top: 10px; + + > li { + display: flex; + } + + .umb-sub-views-nav-item:first { + border-left: none; + } } } +// Validation +.umb-sub-views-nav-item__action.-has-error, .show-validation .umb-sub-views-nav-item > a.-has-error { - color: @red; - &::after { - background-color: @red; - } -} + color: @red; -.umb-sub-views-nav-item .icon { - font-size: 24px; - display: block; - text-align: center; - margin-bottom: 7px; -} - -.umb-sub-views-nav-item .badge { - position: absolute; - top: 6px; - right: 6px; - min-width: 16px; - color: @white; - background-color: @ui-active-type; - border: 2px solid @white; - border-radius: 50%; - font-size: 10px; - font-weight: bold; - padding: 2px; - line-height: 16px; - display: block; - - &.-type-alert { - background-color: @red; - } - &.-type-warning { - background-color: @yellow-d2; - } - &:empty { - height: 12px; - min-width: 12px; - } -} - -.umb-sub-views-nav-item-text { - font-size: 12px; - line-height: 1em; -} - - -.umb-sub-views-nav-item__anchor_dropdown {// inherits from .dropdown-menu - display: block; - margin: 0; - overflow: hidden; - - // center align horizontal - left: 50%; - transform: translateX(-50%); - - visibility:hidden; - opacity: 0; - transition: visibility 0s 500ms, opacity 250ms 250ms; -} -.umb-sub-views-nav-item__anchor_dropdown li a { - border-left: 4px solid transparent; -} -.umb-sub-views-nav-item__anchor_dropdown li.is-active a { - border-left-color: @ui-selected-border; -} - -.umb-sub-views-nav-item:hover .umb-sub-views-nav-item__anchor_dropdown { - visibility:visible; - opacity: 1; - transition: visibility 0s 0s, opacity 20ms 0s; -} - - - -// -------------------------------- -// item__more, appears when there is not enough room for the visible items. -// -------------------------------- - -.umb-sub-views-nav-item-more__icon { - margin-bottom: 10px; -} - -.umb-sub-views-nav-item-more__icon i { - height: 5px; - width: 5px; - border-radius: 50%; - background: @ui-active-type;// fallback if browser doesnt support currentColor - background: currentColor; - display: inline-block; - margin: 0 5px 0 0; -} - -.umb-sub-views-nav-item-more__icon i:last-of-type { - margin-right: 0; -} - -.umb-sub-views-nav-item-more__dropdown { - left: auto; - right: 0; - display: grid; - grid-template-columns: 1fr 1fr 1fr; - min-width: auto; - margin-top: 10px; -} -.umb-sub-views-nav-item-more__dropdown > li { - display: flex; -} -.umb-sub-views-nav-item-more__dropdown .umb-sub-views-nav-item:first { - border-left: none; + &::after { + background-color: @red; + } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-file-dropzone.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-file-dropzone.less index 5071091fcc..b5d8c3cced 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-file-dropzone.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-file-dropzone.less @@ -14,15 +14,18 @@ margin: 0 0 20px 0; position: relative; transition: height 0.8s; + .illustration { width: 300px; } + &.is-small { height: 100px; .illustration { width: 200px; } } + &.drag-over { border: 1px dashed @gray-1; } @@ -35,15 +38,19 @@ top: 50%; left: 50%; transform: translate(-50%,-50%); + display: flex; + flex-direction: column; } // file select link .file-select { + background: transparent; + border: 0; + padding: 0; font-size: 15px; color: @ui-action-discreet-type; cursor: pointer; - margin-top: 10px; &:hover { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-file-icon.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-file-icon.less new file mode 100644 index 0000000000..febee80a97 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-file-icon.less @@ -0,0 +1,60 @@ +.umb-file-icon { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + + .file-icon { + display: flex; + flex-direction: column; + align-items: flex-start; + position: relative; + + > .icon { + font-size: 50px; + line-height: 100%; + color: @gray-4; + display: block; + text-align: center; + } + + > span { + position: absolute; + color: @white; + background: @ui-active; + padding: 1px 3px; + font-size: 10px; + line-height: 130%; + display: block; + margin-bottom: 0.75rem; + min-width: 1.2rem; + bottom: 0; + } + + & + small { + display: block; + margin-top: 0.25rem; + } + } +} + +.umb-file-icon--s { + .file-icon { + > .icon { + + } + } +} + +.umb-file-icon--m { + .file-icon { + > .icon { + font-size: 70px; + } + > span { + font-size: 14px; + margin-bottom: 0.95rem; + min-width: 1.5rem; + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less index deb573920f..76a4df0056 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less @@ -6,16 +6,23 @@ flex-wrap: wrap; align-items: center; position: relative; - padding: 0 !important; + padding: 0 0 0 26px !important; margin: 0; min-height: 22px; line-height: 22px; cursor: pointer !important; + &.-small-text{ + font-size: 13px; + } + + &.-bold{ + font-weight: 700; + } + &__text { - margin: 0 0 0 26px; position: relative; - top: 0; + top: 1px; user-select: none; } @@ -24,7 +31,7 @@ top: 0; left: 0; opacity: 0; - + &:hover ~ .umb-form-check__state .umb-form-check__check { border-color: @inputBorderFocus; } @@ -36,7 +43,7 @@ background: @ui-option-type-hover; } } - + &:checked ~ .umb-form-check__state { .umb-form-check__check { // This only happens if the state has a radiobutton modifier @@ -62,8 +69,7 @@ } } } - - + .tabbing-active &.umb-form-check--radiobutton &__input:focus ~ .umb-form-check__state .umb-form-check__check { //outline: 2px solid @inputBorderTabFocus; border: 2px solid @inputBorderTabFocus; @@ -73,7 +79,12 @@ outline: 2px solid @inputBorderTabFocus; } .tabbing-active &.umb-form-check--checkbox &__input:checked:focus ~ .umb-form-check__state .umb-form-check__check { - border-color: white; + border-color: @white; + } + + // add spacing between when flexed/inline, equal to the width of the input + .flex & + & { + margin-left:@checkboxWidth; } &__state { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-grid-selector.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-grid-selector.less index e25349f555..1ae476d584 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-grid-selector.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-grid-selector.less @@ -41,12 +41,17 @@ margin-top: 10px; } +button.umb-grid-selector__item { + width: 169px; + height: 194px; +} + .umb-grid-selector__item-icon { - font-size: 50px; - color: @gray-8; - display: block; - line-height: 50px; - margin-bottom: 15px; + font-size: 50px; + color: @gray-8; + display: block; + line-height: 50px; + margin-bottom: 15px; } .umb-grid-selector__item-label { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less index 2eafe9b3d7..26d61412ae 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-grid.less @@ -162,6 +162,8 @@ } .umb-grid .umb-row .umb-cell-placeholder { + display: block; + width: 100%; min-height: 88px; border-width: 1px; border-style: dashed; @@ -226,6 +228,7 @@ .umb-grid .cell-tools-add.-bar { display: block; + width: calc(100% - 20px); text-align: center; padding: 5px; border: 1px dashed @ui-action-discreet-border; @@ -408,7 +411,19 @@ margin-bottom: 10px; } +.umb-grid .umb-editor-preview { + position: relative; + .umb-editor-preview-overlay { + cursor: pointer; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1; + } +} // Active states // ------------------------- @@ -425,7 +440,7 @@ .umb-grid .umb-row.-active-child { background-color: @gray-10; - + .umb-row-title-bar { cursor: inherit; } @@ -433,7 +448,7 @@ .umb-row-title { color: @gray-3; } - + } @@ -570,10 +585,10 @@ .umb-grid .iconBox.selected { -webkit-appearance: none; - background-image: linear-gradient(to bottom,#e6e6e6,#bfbfbf); + background-image: linear-gradient(to bottom,@gray-9,@gray-7); background-repeat: repeat-x; zoom: 1; - border-color: #bfbfbf #bfbfbf #999; + border-color: @gray-7 @gray-7 @gray-6; border-color: rgba(0,0,0,0.1) rgba(0,0,0,0.1) rgba(0,0,0,0.25); box-shadow: inset 0 2px 4px rgba(0,0,0,0.15),0 1px 2px rgba(0,0,0,0.05); border-radius: 3px; @@ -625,9 +640,12 @@ } .umb-grid .mce-toolbar { - border-bottom: 1px solid @gray-8; - background-color: rgba(250, 250, 250, 1); + border-bottom: 1px solid @gray-7; + background-color: @white; display: none; + + left: 0; + right: 0; } .umb-grid .umb-control.-active .mce-toolbar { @@ -642,6 +660,13 @@ overflow-y: hidden!important; } +// had to overwrite defaults from TinyMCE, needed for buttons panel to float to new line in narrow space. +.umb-grid .mce-container > div { + white-space: normal; + left:0; + right:0; +} + // MEDIA EDITOR // ------------------------- diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less index 4c23aef5f0..f9d8772d45 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less @@ -367,7 +367,6 @@ input.umb-group-builder__group-title-input:disabled:hover { overflow: visible; background: transparent; line-height: normal; - outline: 0; -webkit-appearance: none; &:hover, &:focus { @@ -494,11 +493,11 @@ input.umb-group-builder__group-sort-value { font-weight: bold; font-size: 14px; color: @ui-action-type; - - &:hover{ + + &:hover { text-decoration: none; - color:@ui-action-type-hover; - border-color:@ui-action-border-hover; + color: @ui-action-type-hover; + border-color: @ui-action-border-hover; } } @@ -555,7 +554,13 @@ input.umb-group-builder__group-sort-value { overflow: hidden; } - .editor-validation-pattern{ + .editor-validation-message { + min-width: 100%; + min-height: 25px; + margin-top: 4px; + } + + .editor-validation-pattern { border: 1px solid @gray-7; margin: 10px 0 0; padding: 6px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less index 34070256ce..98b2b1d72d 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-iconpicker.less @@ -15,7 +15,9 @@ overflow: hidden; } -.umb-iconpicker-item a { +.umb-iconpicker-item button { + background: transparent; + border: 0 none; display: flex; justify-content: center; align-items: center; @@ -26,8 +28,8 @@ border-radius: 3px; } -.umb-iconpicker-item a:hover, -.umb-iconpicker-item a:focus { +.umb-iconpicker-item button:hover, +.umb-iconpicker-item button:focus { background: @gray-10; outline: none; } @@ -39,7 +41,7 @@ box-sizing: border-box; } -.umb-iconpicker-item a:active { +.umb-iconpicker-item button:active { background: @gray-10; } @@ -51,7 +53,7 @@ // Color swatch .button { border: none; - color: white; + color: @white; padding: 5px; text-align: center; text-decoration: none; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-insert-code-box.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-insert-code-box.less index a87e7084fb..f3b53f4def 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-insert-code-box.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-insert-code-box.less @@ -11,7 +11,7 @@ cursor: pointer; } -.umb-insert-code-box:hover, +.umb-insert-code-box:hover, .umb-insert-code-box.-selected { background-color: @ui-option-hover; color: @ui-action-type-hover; @@ -32,7 +32,7 @@ .umb-insert-code-box__check { width: 18px; height: 18px; - background: @gray-10;x + background: @gray-10; border-radius: 50%; display: flex; align-items: center; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-layout-selector.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-layout-selector.less index cf407b667f..c0ac89622d 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-layout-selector.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-layout-selector.less @@ -4,8 +4,10 @@ } .umb-layout-selector__active-layout { + background: transparent; box-sizing: border-box; - border: 1px solid @inputBorder; + border: 1px solid @ui-action-discreet-border; + color: @ui-action-discreet-type; cursor: pointer; height: 30px; width: 30px; @@ -16,23 +18,26 @@ } .umb-layout-selector__active-layout:hover { - border-color: @inputBorderFocus; + border-color: @ui-action-discreet-border-hover; + color: @ui-action-discreet-type-hover; } .umb-layout-selector__dropdown { position: absolute; padding: 5px; - background: #333; + background: @grayDark; z-index: 999; display: flex; - background: #fff; + background: @white; flex-wrap: wrap; flex-direction: column; transform: translate(-50%,0); left: 50%; + border-radius: 3px; } .umb-layout-selector__dropdown-item { + background: transparent; padding: 5px; margin: 3px 5px; display: flex; @@ -44,11 +49,11 @@ } .umb-layout-selector__dropdown-item:hover { - border: 1px solid @gray-8; + border: 1px solid @ui-action-discreet-border; } .umb-layout-selector__dropdown-item.-active { - border: 1px solid @blue; + border: 1px solid @ui-action-discreet-border-hover; } .umb-layout-selector__dropdown-item-icon, diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-list-view-settings.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-list-view-settings.less index 733836d85f..ba46c68a57 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-list-view-settings.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-list-view-settings.less @@ -1,54 +1,76 @@ .umb-list-view-settings__box { - background: @gray-10; - border: 1px solid @gray-7; - display: flex; - animation: fadeIn 0.5s; - padding: 15px; - position: relative; + background: @gray-10; + display: flex; + flex: 1; + padding: 15px; + position: relative; + border-radius: @baseBorderRadius; + + .btn-link { + font-size: 13px; + padding: 0; + } } .umb-list-view-settings__trigger { - margin-bottom: 20px; -} - -.umb-list-view-settings__box.-open { - border-bottom: transparent; -} - -.umb-list-view-settings__content { - display: flex; + margin-bottom: 20px; } .umb-list-view-settings__list-view-icon { - font-size: 20px; - color: @gray-7; - margin-right: 10px; + font-size: 20px; + color: @gray-7; + margin-right: 10px; } .umb-list-view-settings__name { - margin-right: 5px; - font-size: 14px; - font-weight: bold; - float: left; + font-size: 14px; + font-weight: bold; } .umb-list-view-settings__create-new { - font-size: 13px; - color: @ui-action-type; -} - -.umb-list-view-settings__create-new:hover { - color: @ui-action-type-hover; - border-color: @ui-action-type-hover; + color: @ui-action-type; } .umb-list-view-settings__remove-new { - font-size: 13px; - color: @red; + color: @red; } -.umb-list-view-settings__settings { - border: 1px dashed @gray-7; - border-top: none; - padding: 20px; +// display `columns displayed` table as a list-view layout +.umb-list-view-settings__overlay { + + .btn { + vertical-align: top; + } + + .btn-icon { + padding: 0; + } + + table { + width: 100%; + } + + tbody tr { + background: @gray-10; + border-bottom: 1px solid @white; + } + + th { + text-align: left; + } + + td { + padding: 10px 15px 10px 0; + + &:first-child { + padding-left: 15px; + } + + .ui-sortable-handle { + min-height: 37px; + display: flex; + width:0; + align-items: center; + } + } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-list.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-list.less index 12d7085b0a..94cfa6f62c 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-list.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-list.less @@ -36,6 +36,10 @@ a.umb-list-item:focus { color: @gray-4; } +.umb-list-item__description--checkbox{ + margin: 0 0 0 26px; +} + .umb-list-checkbox { position: absolute; opacity: 0; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-loader.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-loader.less new file mode 100644 index 0000000000..260710ce72 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-loader.less @@ -0,0 +1,42 @@ +// Loading Animation +// ------------------------ + +.umb-loader { + background-color: @blue; + margin-top: 0; + margin-left: -100%; + animation-name: bounce_loadingProgressG; + animation-duration: 1s; + animation-iteration-count: infinite; + animation-timing-function: linear; + width: 100%; + height: 2px; +} + +@keyframes bounce_loadingProgressG { + 0% { + margin-left: -100%; + } + + 100% { + margin-left: 100%; + } +} + +.umb-loader-wrapper { + position: absolute; + right: 0; + left: 0; + margin: 10px 0; + overflow: hidden; +} + +.umb-loader-wrapper.-top { + top: 0; + bottom: auto; +} + +.umb-loader-wrapper.-bottom { + top: auto; + bottom: 0; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-locked-field.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-locked-field.less index 8d9ae86ce7..2b49348190 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-locked-field.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-locked-field.less @@ -6,13 +6,26 @@ } .umb-locked-field__wrapper { - display: flex; - align-items: center; - margin-bottom: 5px; + display: flex; + align-items: center; + margin-bottom: 5px; } .umb-locked-field__toggle { margin-right: 3px; + padding: 0; + background: none; + border: 0; + font-size: inherit; + line-height: inherit; + + &:focus { + outline: none; + + .tabbing-active & { + outline: 2px solid @inputBorderTabFocus; + } + } } .umb-locked-field__toggle:hover, @@ -29,7 +42,8 @@ color: @gray-3; } -input.umb-locked-field__input { +input.umb-locked-field__input, +.umb-locked-field__text { background: rgba(255, 255, 255, 0); // if using transparent it will hide the text in safari border-color: transparent !important; font-size: 13px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-logviewer.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-logviewer.less index 7b8845542e..8beff55b7c 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-logviewer.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-logviewer.less @@ -1,5 +1,3 @@ -/* PACKAGE DETAILS */ - .umb-logviewer { display: flex; flex-flow: row wrap; @@ -41,7 +39,7 @@ flex: 1 1 auto; width: 100%; margin-bottom: 30px; - margin-right: 0; + margin-right: 0; } .umb-logviewer__sidebar { @@ -49,3 +47,99 @@ width: 100%; } } + +.umb-logviewer-search { + .filter-name { + margin-left: 5px; + margin-right: 3px; + max-width: 150px; + } + + .dropdown-item { + padding: 8px 20px 8px 16px; + } + + .filter { + position: relative; + + a.btn-link { + padding-left: 0px; + } + } + + .search-box { + width: 100%; + + .flex-auto { + position: relative; + + .search-input { + width: 100%; + padding-right: 160px; + } + + .icon-rate { + position: absolute; + top: 0; + line-height: 32px; + right: 140px; + color: @yellow-d1; + cursor: pointer; + } + + .icon-wrong { + position: absolute; + top: 0; + line-height: 32px; + right: 120px; + color: @gray-7; + cursor: pointer; + } + + .umb-variant-switcher__toggle { + top: 1px; + right: 0; + position: absolute; + + .icon-navigation-down { + margin-top: 0; + } + } + + .saved-searches { + width: 100%; + max-height: 250px; + overflow-y: scroll; + margin-top: -10px; + } + } + } + + .log-items { + .table { + table-layout: fixed; + + thead th:first-child, thead th:nth-child(3) { + width: 20%; + } + + thead th:nth-child(2) { + width: 15%; + } + + tr td:nth-child(3) { + word-break: break-word; + } + } + + .exception { + border-left: 4px solid @red; + padding: 0 10px 10px 10px; + box-shadow: rgba(0,0,0,0.07) 2px 2px 10px; + + .exception-message { + white-space: pre-wrap; + } + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-media-grid.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-media-grid.less index 68973b4c7c..5d6b7ad962 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-media-grid.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-media-grid.less @@ -21,29 +21,61 @@ margin: 10px; position: relative; - //overflow: hidden; - user-select: none; - - cursor: pointer; - box-shadow: 0 1px 1px 0 rgba(0,0,0,.2); - //border: 2px solid transparent; - transition: box-shadow 150ms ease-in-out; } +.umb-media-grid__item.-unselectable { + &::before { + content: ""; + position: absolute; + z-index: 1; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(230, 230, 230, .5); + pointer-events: none; + } +} + +.umb-media-grid__item.-selectable, +.umb-media-grid__item.-folder {// If folders isnt selectable, they opens if clicked, therefor... + cursor: pointer; +} + .umb-media-grid__item.-file { background-color: @white; } +.umb-media-grid__item.-folder { + + &.-selectable { + .media-grid-item-edit:hover .umb-media-grid__item-name, + .media-grid-item-edit:focus .umb-media-grid__item-name { + text-decoration: underline; + } + } + + &.-unselectable { + &:hover, &:focus { + .umb-media-grid__item-name { + text-decoration: underline; + } + } + } +} + + .umb-media-grid__item.-selected { color:@ui-selected-type; .umb-media-grid__item-overlay { color: @ui-selected-type; } } -.umb-media-grid__item.-selected, .umb-media-grid__item:hover { +.umb-media-grid__item.-selected, +.umb-media-grid__item.-selectable:hover { &::before { content: ""; position: absolute; @@ -58,7 +90,7 @@ pointer-events: none; } } -.umb-media-grid__item:hover { +.umb-media-grid__item.-selectable:hover { &::before { opacity: .33; } @@ -69,16 +101,11 @@ } } -.umb-media-grid__item-file-icon > span { - color: @white; - background: @gray-4; - padding: 1px 3px; - font-size: 10px; - line-height: 130%; - display: block; - margin-top: -30px; - margin-left: -10px; - position: relative; +.umb-media-grid__item-file-icon { + transform: translate(-50%,-50%); + position: absolute; + top: 45%; + left: 50%; } .umb-media-grid__item:hover { @@ -86,38 +113,32 @@ } .umb-media-grid__item-image { - //max-width: 100% !important; - //height: auto; position: relative; - object-fit: contain; height: 100%; } .umb-media-grid__item-image-placeholder { width: 100%; - //max-width: 100%; - //height: auto; position: relative; - object-fit: contain; height: 100%; } .umb-media-grid__image-background { content: ""; - background: url("../img/checkered-background.png"); - background-repeat: repeat; opacity: 0.5; top: 0; left: 0; bottom: 0; right: 0; position: absolute; + .checkeredBackground(); } .umb-media-grid__item-overlay { display: flex; + width: 100%; opacity: 0; position: absolute; right: 0; @@ -130,28 +151,19 @@ overflow: hidden; color: @black; white-space: nowrap; - border-top:1px solid fade(black, 4%); + border-top:1px solid fade(@black, 4%); background: fade(@white, 92%); transition: opacity 150ms; - - &:hover { + + &.-can-open:hover { text-decoration: underline; } + + .tabbing-active &:focus { + opacity: 1; + } } -/* -.umb-media-grid__item.-file .umb-media-grid__item-overlay { - opacity: 1; - color: @gray-4; - background: @white; -} - -.umb-media-grid__item.-file:hover .umb-media-grid__item-overlay, -.umb-media-grid__item.-file.-selected .umb-media-grid__item-overlay { - color: @white; - background: @blueExtraDark; -} -*/ .umb-media-grid__info { margin-right: 5px; } @@ -167,39 +179,17 @@ } } +.umb-media-grid__item-name { + cursor: pointer; +} + .umb-media-grid__item-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.umb-media-grid__item-icon { - color: @gray-4; - position: absolute; - top: 45%; - left: 50%; - font-size: 40px !important; - transform: translate(-50%,-50%); -} -/* -.umb-media-grid__checkmark { - position: absolute; - z-index: 2; - top: 10px; - left: 10px; - width: 26px; - height: 26px; - border: 2px solid @white; - background: @green; - border-radius: 50px; - box-sizing: border-box; - display: flex; - justify-content: center; - align-items: center; - color: @white; - transition: background 100ms; -} -*/ + .umb-media-grid__edit { position: absolute; opacity: 0; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-mini-list-view.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-mini-list-view.less index 7a2939bd30..8cd08f5045 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-mini-list-view.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-mini-list-view.less @@ -13,9 +13,15 @@ margin-right: 5px; } +.umb-mini-list-view__breadcrumb { + .flex; + margin-bottom: 10px; + min-height: 25px; +} + .umb-mini-list-view__back { - font-size: 12px; - margin-right: 5px; + font-size: 13px; + margin-right: 5px; color: @gray-4; display: flex; align-items: center; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-mini-search.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-mini-search.less new file mode 100644 index 0000000000..ac15b3dcf8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-mini-search.less @@ -0,0 +1,44 @@ +.umb-mini-search { + position: relative; + display: block; + + .icon { + position: absolute; + padding: 5px 8px; + pointer-events: none; + top: 2px; + color: @ui-action-discreet-type; + transition: color .1s linear; + } + + input { + width: 0px; + padding-left:24px; + margin-bottom: 0px; + background-color: transparent; + border-color: @ui-action-discreet-border; + transition: background-color .1s linear, border-color .1s linear, color .1s linear, width .1s ease-in-out, padding-left .1s ease-in-out; + } + + &:focus-within, &:hover { + .icon { + color: @ui-action-discreet-type-hover; + } + input { + color: @ui-action-discreet-border-hover; + border-color: @ui-action-discreet-border-hover; + } + } + + input:focus, &:focus-within input { + background-color: white; + color: @ui-action-discreet-border-hover; + border-color: @ui-action-discreet-border-hover; + } + + input:focus, &:focus-within input, &.--has-value input { + width: 190px; + padding-left:30px; + } + +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less index 59c90972d2..4168ab3c39 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-nested-content.less @@ -1,5 +1,4 @@ .umb-nested-content { - text-align: center; position: relative; } @@ -15,6 +14,17 @@ pointer-events: none; } +.umb-nested-content--mandatory { + /* + yeah so this is a pain, but we must be super specific in targeting the mandatory property labels, + otherwise all properties within a reqired, nested, nested content property will all appear mandatory + */ + > ng-form > .control-group > .umb-el-wrap > .control-header label:after { + content: '*'; + color: @red; + } +} + .umb-nested-content-overlay { position: absolute; top: 0; @@ -31,11 +41,8 @@ } .umb-nested-content__item.ui-sortable-placeholder { - background: @gray-10; - border: 1px solid @gray-9; + margin-top: 1px; visibility: visible !important; - height: 55px; - margin-top: -1px; } .umb-nested-content__item--single > .umb-nested-content__content { @@ -72,7 +79,7 @@ padding: 15px 5px; color:@ui-option-type; border-radius: 3px 3px 0 0; - + &:hover { color:@ui-option-type-hover; } @@ -94,7 +101,7 @@ padding-left: 30px; } } - + } .umb-nested-content__icons { @@ -135,6 +142,8 @@ .umb-nested-content__icon { + background: transparent; + border: 0 none; display: inline-block; padding: 4px; margin: 2px; @@ -154,6 +163,11 @@ .umb-nested-content__icon--disabled { opacity: 0.3; + cursor: default !important; + + &:hover { + color: @ui-option-type; + } } @@ -165,6 +179,7 @@ .umb-nested-content__add-content { display: flex; + width: 100%; align-items: center; justify-content: center; border: 1px dashed @ui-action-discreet-border; @@ -208,19 +223,19 @@ display: none !important; } -.umb-nested-content__doctypepicker table input, +.umb-nested-content__doctypepicker table input, .umb-nested-content__doctypepicker table select { width: 100%; padding-right: 0; } -.umb-nested-content__doctypepicker table td.icon-navigation, +.umb-nested-content__doctypepicker table td.icon-navigation, .umb-nested-content__doctypepicker i.umb-nested-content__help-icon { vertical-align: middle; color: @gray-7; } -.umb-nested-content__doctypepicker table td.icon-navigation:hover, +.umb-nested-content__doctypepicker table td.icon-navigation:hover, .umb-nested-content__doctypepicker i.umb-nested-content__help-icon:hover { color: @gray-2; } @@ -229,8 +244,30 @@ margin-left: 10px; } -.form-horizontal .umb-nested-content--narrow .controls-row -{ +.umb-nested-content__placeholder { + padding: 4px 6px; + border: 1px dashed @gray-8; + background: 0 0; + cursor: pointer; + color: @blueExtraDark; + -webkit-animation: fadeIn .5s; + animation: fadeIn .5s; + text-align: center; + + &--selected { + border: none; + text-align: left; + padding: 0; + } +} + +.umb-nested-content__placeholder:hover { + color: @blueMid; + border-color: @blueMid; + text-decoration: none; +} + +.form-horizontal .umb-nested-content--narrow .controls-row { margin-left: 40% !important; } @@ -248,7 +285,7 @@ // the attribute selector ensures the change only applies to the linkpicker overlay .form-horizontal .umb-nested-content--narrow [ng-controller*="Umbraco.Overlays.LinkPickerController"] .controls-row { margin-left:0!important; - + .umb-textarea, .umb-textstring { width:100%; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less index 1edaffe824..f754a09368 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-node-preview.less @@ -3,6 +3,7 @@ display: flex; box-sizing: border-box; border-bottom: 1px solid @gray-9; + flex-wrap: wrap; } .umb-editor-wrapper .umb-node-preview { @@ -64,9 +65,13 @@ flex: 0 0 auto; display: flex; align-items: center; + margin-left: auto; } .umb-node-preview__action { + background: transparent; + padding: 0; + border: 0 none; margin-left: 5px; margin-right: 5px; font-size: 13px; @@ -89,11 +94,13 @@ display: flex; align-items: center; justify-content: center; + background: transparent; border: 1px dashed @ui-action-discreet-border; color: @ui-action-discreet-type; font-weight: bold; padding: 5px 15px; box-sizing: border-box; + width: 100%; } .umb-node-preview-add:hover { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-packages.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-packages.less index f704dd48e2..b808e6574a 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-packages.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-packages.less @@ -67,6 +67,7 @@ height: 100%; width: 100%; border-radius: 3px; + border: 0 none; text-decoration: none !important; transition: border-color 100ms ease; background-color: @white; @@ -193,9 +194,8 @@ color: @black; box-sizing: border-box; justify-content: center; - border-top: 1px solid @gray-8; - border-bottom: 1px solid @gray-8; - border-right: 1px solid @gray-8; + border: 1px solid @gray-8; + border-left: 0; padding: 10px 0; background: @white; } @@ -264,12 +264,16 @@ flex-flow: row wrap; } -a.umb-package-details__back-link { +.umb-package-details__back-action { font-weight: bold; color: @black; + padding: 0; + border: 0; + background-color: transparent; } -.umb-package-details__back-link:hover { +.umb-package-details__back-action:focus, +.umb-package-details__back-action:hover { color: @gray-4; text-decoration: none; } @@ -407,6 +411,7 @@ a.umb-package-details__back-link { } .umb-gallery__thumbnail { + background: transparent; flex: 0 1 100px; border: 1px solid @ui-action-discreet-border; border-radius: 3px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-panel-group.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-panel-group.less index 487c1881b1..46d7f04af6 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-panel-group.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-panel-group.less @@ -107,5 +107,5 @@ .umb-panel-group__details-status-action-description { margin-top: 5px; font-size: 12px; - padding-left: 165px; + padding-left:165px; } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-progress-circle.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-progress-circle.less index 03816637a7..d8fb3b4d8f 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-progress-circle.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-progress-circle.less @@ -5,6 +5,7 @@ .umb-progress-circle__view-box { position: absolute; transform: rotate(-90deg); + right: 0; } // circle highlight on progressbar diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-property-actions.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-property-actions.less new file mode 100644 index 0000000000..3f0b981ac6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-property-actions.less @@ -0,0 +1,96 @@ +.umb-property-actions { + display: inline; +} + +.umb-property-actions__toggle, +.umb-property-actions__menu-open-toggle { + position: relative; + display: flex; + flex: 0 0 auto; + padding: 6px 6px; + text-align: center; + cursor: pointer; + border-radius: 3px; + + background-color: @ui-action-hover; + + i { + height: 3px !important; + width: 3px !important; + border-radius: 3px; + background: @ui-action-type; + display: inline-block; + margin: 0 2px 0 0; + + &:last-child { + margin: 0; + } + } + &:hover { + i { + background: @ui-action-type-hover; + } + } +} +.umb-property-actions__menu-open-toggle { + position: absolute; + z-index:1; + outline: none;// this is not acceccible by keyboard, since we use the .umb-property-actions__toggle for that. + + top: -15px; + border-radius: 3px 3px 0 0; + + border-top-left-radius: 3px; + border-top-right-radius: 3px; + + border: 1px solid @dropdownBorder; + + border-bottom: 1px solid @gray-9; + + .box-shadow(0 5px 20px rgba(0,0,0,.3)); + + background-color: @white; + +} + +.umb-property .umb-property-actions { + float: left; +} +.umb-property .umb-property-actions__toggle { + margin-top: 2px; + opacity: 0; + transition: opacity 120ms; +} +.umb-property:hover .umb-property-actions__toggle, +.umb-property .umb-property-actions__toggle:focus { + opacity: 1; +} +// Revert-style-hack that ensures that we only show property-actions on properties that are directly begin hovered. +.umb-property:hover .umb-property:not(:hover) .umb-property-actions__toggle { + opacity: 0; +} + +.umb-property-actions__menu { + + position: absolute; + z-index: 1000; + + display: block; + + float: left; + min-width: 160px; + list-style: none; + + .umb-contextmenu { + + border-top-left-radius: 0; + margin-top:1px; + + } + + .umb-contextmenu-item > button { + + z-index:2;// need to stay on top of menu-toggle-open shadow. + + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-range-slider.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-range-slider.less index 1461d0f223..6ae92ffa4e 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-range-slider.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-range-slider.less @@ -1,7 +1,7 @@ .umb-range-slider.noUi-target { - background: linear-gradient(to bottom, #f5f5f5 0%, #f9f9f9 100%); + background: linear-gradient(to bottom, @grayLighter 0%, @grayLighter 100%); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); border-radius: 20px; height: 10px; diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-sub-views.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-sub-views.less index d3ce368356..cc6be8fa37 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-sub-views.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-sub-views.less @@ -8,7 +8,6 @@ padding-left: 0; padding-right: 0; &:focus { - outline: none; text-decoration: none; } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-table.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-table.less index f387b6540b..202c488bb4 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/umb-table.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-table.less @@ -27,16 +27,19 @@ } +.umb-table__action, .umb-table a { + background: transparent; + border: 0 none; text-decoration: none; - cursor: pointer; - - &:focus { - outline: none; - text-decoration: none; + padding: 0; + margin-left: 1px; + body:not(.tabbing-active) & { + outline: 0; } } + input.umb-table__input { margin: 0 auto; } @@ -48,25 +51,35 @@ input.umb-table__input { .umb-table-head { font-size: 14px; font-weight: bold; + + color: @ui-disabled-type; } .umb-table-head__link { + background: transparent; + border: 0 none; position: relative; - cursor: default; text-decoration: none; color: @gray-3; + font-size: inherit; + font-weight: inherit; + padding: 0 1px; + &:hover { text-decoration: none; + cursor: default; color: @gray-3; } } -.umb-table-head__link .sortable { +.umb-table-head__link.sortable { + cursor: pointer; + color: @ui-action-discreet-type; &:hover { - text-decoration: none; - cursor: pointer; - color: @black; + color: @ui-action-discreet-type-hover; } + + outline-offset: 1px; } .umb-table-thead__icon { @@ -124,6 +137,9 @@ input.umb-table__input { &::before { opacity:.66; } + .umb-table-body__checkicon { + color: @ui-selected-border; + } } } @@ -136,34 +152,32 @@ input.umb-table__input { } .umb-table-body__link { - + position: relative; color: @ui-option-type; font-size: 14px; font-weight: bold; text-decoration: none; - - &:hover, &:focus { + + &:hover { color: @ui-option-type-hover; text-decoration: underline; } } -.umb-table-body__icon, -.umb-table-body__icon[class^="icon-"], -.umb-table-body__icon[class*=" icon-"] { +.umb-table-body__icon { margin: 0 auto; font-size: 20px; line-height: 20px; color: @ui-option-type; + vertical-align: bottom; + text-decoration: none; } -.umb-table-body__checkicon, -.umb-table-body__checkicon[class^="icon-"], -.umb-table-body__checkicon[class*=" icon-"] { +.umb-table-body__checkicon { display: none; font-size: 18px; line-height: 20px; - color: @green; + color: @ui-selected-border; } .umb-table-body .umb-table__name { @@ -172,7 +186,8 @@ input.umb-table__input { font-weight: bold; a { color: @ui-option-type; - &:hover, &:focus { + outline-offset: 1px; + &:hover { color: @ui-option-type-hover; text-decoration: underline; } @@ -240,10 +255,10 @@ input.umb-table__input { .umb-table-cell { display: flex; flex-flow: row nowrap; - flex: 1 1 1%; //NOTE 1% is a Internet Explore hack, so that cells don't collapse + flex: 1 1 5%; position: relative; - margin: auto 14px; - padding: 6px 2px; + margin: auto 0; + padding: 6px 16px; text-align: left; overflow:hidden; } @@ -253,17 +268,25 @@ input.umb-table__input { white-space: nowrap; //NOTE Disable/Enable this to keep textstring on one line text-overflow: ellipsis; } +.umb-table-cell.--noOverflow > * { + overflow: visible; + white-space: normal; + text-overflow: unset; +} .umb-table-cell:first-of-type:not(.not-fixed) { flex: 0 0 25px; - margin: 0 0 0 15px; - padding: 15px 0; + margin: 0; + padding: 15px 0 15px 15px; } .umb-table-cell--auto-width { flex: 0 0 auto !important; } +.umb-table-cell--nano { + flex: 0 0 50px; +} .umb-table-cell--small { flex: .5 .5 1%; max-width: 12.5%; @@ -280,8 +303,8 @@ input.umb-table__input { // Increases the space for the name cell .umb-table__name { - flex: 1 1 25%; - max-width: 25%; + flex: 1 1 20%; + max-width: 300px; } .umb-table__loading-overlay { @@ -295,7 +318,11 @@ input.umb-table__input { .umb-table__row-expand { font-size: 12px; text-decoration: none; - color: @black; + color: @gray-4; + + &:hover { + color: @black; + } } .umb-table__row-expand--hidden { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umbemailmarketing.less b/src/Umbraco.Web.UI.Client/src/less/components/umbemailmarketing.less new file mode 100644 index 0000000000..f4b3183045 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/components/umbemailmarketing.less @@ -0,0 +1,44 @@ +.umb-email-marketing { + + h2 { + font-weight: 800; + max-width: 26ex; + margin-top: 20px; + } + + .layout { + display: flex; + align-items: center; + align-content: stretch; + + .primary { + flex-basis: 50%; + padding-right: 40px; + padding-top: 20px; + padding-bottom: 20px; + .notice { + color: @gray-5; + font-style: italic; + a { + color: @gray-5; + &:hover { + color: @ui-action-type-hover; + } + } + } + } + + .secondary { + flex-basis: 50%; + svg { + height: 200px; + width: 100%; + margin-top: -60px; + } + } + } + + .cta { + text-align: right; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-details.less b/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-details.less index 9ddad03b48..a612af65ef 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-details.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-details.less @@ -1,7 +1,7 @@ .umb-user-details-avatar { margin-bottom: 20px; padding-bottom: 20px; - border-bottom: 1px solid #d8d7d9; + border-bottom: 1px solid @gray-8; } div.umb-user-details-actions > div { @@ -63,6 +63,10 @@ a.umb-user-details-details__back-link { .umb-user-details-details__sidebar { flex: 0 0 @sidebarwidth; + + .umb-button{ + margin-left:0px; + } } @media (max-width: 768px) { @@ -77,7 +81,7 @@ a.umb-user-details-details__back-link { margin-bottom: 30px; margin-right: 0; } - + .umb-user-details-details__sidebar { flex: 1 1 auto; width: 100%; @@ -101,6 +105,7 @@ a.umb-user-details-details__back-link { .umb-user-details-details__information-item { margin-bottom: 10px; font-size: 13px; + margin-top:10px; } .umb-user-details-details__information-item-label { diff --git a/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-group-picker-list.less b/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-group-picker-list.less index dff78ce627..0a06120b11 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-group-picker-list.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/users/umb-user-group-picker-list.less @@ -7,11 +7,17 @@ display: flex; margin-bottom: 5px; padding: 10px; + position: relative; } -.umb-user-group-picker-list-item:active, -.umb-user-group-picker-list-item:focus { - text-decoration: none; +.umb-user-group-picker__action{ + background: transparent; + border: 0 none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; } .umb-user-group-picker-list-item:hover { @@ -35,4 +41,4 @@ .umb-user-group-picker-list-item__permission { font-size: 13px; color: @gray-4; -} \ No newline at end of file +} diff --git a/src/Umbraco.Web.UI.Client/src/less/dashboards/examine-management.less b/src/Umbraco.Web.UI.Client/src/less/dashboards/examine-management.less index 0c219af1e4..7b842c40ad 100644 --- a/src/Umbraco.Web.UI.Client/src/less/dashboards/examine-management.less +++ b/src/Umbraco.Web.UI.Client/src/less/dashboards/examine-management.less @@ -12,4 +12,9 @@ border-bottom-left-radius: 0; } } + + .umb-panel-group__details-status-action{ + background-color:transparent; + padding-left:0; + } } diff --git a/src/Umbraco.Web.UI.Client/src/less/dashboards/getstarted.less b/src/Umbraco.Web.UI.Client/src/less/dashboards/getstarted.less index 807d0e863f..d64ffef098 100644 --- a/src/Umbraco.Web.UI.Client/src/less/dashboards/getstarted.less +++ b/src/Umbraco.Web.UI.Client/src/less/dashboards/getstarted.less @@ -22,8 +22,8 @@ text-align: center; display: flex; align-items: center; - border: 1px solid #d8d7d9; - background-color: #fff; + border: 1px solid @gray-8; + background-color: @white; margin: 0 0 0.5em; @media (min-width: 500px) { diff --git a/src/Umbraco.Web.UI.Client/src/less/dashboards/healthcheck.less b/src/Umbraco.Web.UI.Client/src/less/dashboards/healthcheck.less index 449cc4066b..bf01c21dca 100644 --- a/src/Umbraco.Web.UI.Client/src/less/dashboards/healthcheck.less +++ b/src/Umbraco.Web.UI.Client/src/less/dashboards/healthcheck.less @@ -8,7 +8,6 @@ .umb-healthcheck-help-text { line-height: 1.6em; - max-width: 750px; } .umb-healthcheck-action-bar { @@ -22,15 +21,18 @@ .umb-healthcheck-group { display: flex; flex-wrap: wrap; - flex-direction: column; - background: @white; + flex-direction: column; + align-items: center; + background: @white; + border: 0; border-radius: 3px; padding: 20px; box-sizing: border-box; text-align: center; box-shadow: 0 1px 1px 0 rgba(0,0,0,0.16); height: 100%; - box-sizing: border-box; + box-sizing: border-box; + width: 100%; } .umb-healthcheck-group:hover { @@ -117,7 +119,10 @@ /* DETAILS */ .umb-healthcheck-back-link { - font-weight: bold; + background: transparent; + border: 0 none; + padding: 0; + font-weight: bold; color: @black; } diff --git a/src/Umbraco.Web.UI.Client/src/less/dashboards/nucache.less b/src/Umbraco.Web.UI.Client/src/less/dashboards/nucache.less new file mode 100644 index 0000000000..91922300fb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/dashboards/nucache.less @@ -0,0 +1,13 @@ +#nuCache { + .no-background { + background-color: transparent; + } + + .top-border { + border-top: 2px solid @gray-10; + } + + .no-left-padding { + padding-left: 0; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/dashboards/umbraco-forms.less b/src/Umbraco.Web.UI.Client/src/less/dashboards/umbraco-forms.less index b51bfeffa9..21ec047d41 100644 --- a/src/Umbraco.Web.UI.Client/src/less/dashboards/umbraco-forms.less +++ b/src/Umbraco.Web.UI.Client/src/less/dashboards/umbraco-forms.less @@ -106,7 +106,7 @@ .step-text { font-size: 16px; line-height: 1.5; - color: #4c4c4c; + color: @gray; margin-bottom: 20px; } diff --git a/src/Umbraco.Web.UI.Client/src/less/filter-toggle.less b/src/Umbraco.Web.UI.Client/src/less/filter-toggle.less new file mode 100644 index 0000000000..82f9f3f553 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/filter-toggle.less @@ -0,0 +1,20 @@ +.filter-toggle{ + margin: 0; + padding: 0 8px 0 0; + position: relative; +} + +.filter-toggle__level{ + display: inline-block; + font-weight: 700; + margin: 0 5px; + max-width: 150px; +} + +.filter-toggle__icon{ + position: absolute; + top: 0; + bottom: 0; + right: 0; + margin: auto 0; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/footer.less b/src/Umbraco.Web.UI.Client/src/less/footer.less deleted file mode 100644 index 93fdb6ab5e..0000000000 --- a/src/Umbraco.Web.UI.Client/src/less/footer.less +++ /dev/null @@ -1,2 +0,0 @@ -// Footer -// ------------------------- diff --git a/src/Umbraco.Web.UI.Client/src/less/forms.less b/src/Umbraco.Web.UI.Client/src/less/forms.less index cfbb8b78ab..0600c9aab6 100644 --- a/src/Umbraco.Web.UI.Client/src/less/forms.less +++ b/src/Umbraco.Web.UI.Client/src/less/forms.less @@ -252,7 +252,7 @@ input[type="color"], outline: 0; .tabbing-active & { - outline: 2px solid @inputBorderTabFocus; + outline: 2px solid @ui-outline; } } } @@ -297,11 +297,11 @@ select[size] { } // Focus for select, file, radio, and checkbox -select:focus, -input[type="file"]:focus, -input[type="radio"]:focus, -input[type="checkbox"]:focus { - .tab-focus(); +select, +input[type="file"], +input[type="radio"], +input[type="checkbox"] { + .umb-outline(); } @@ -528,7 +528,8 @@ input[type="checkbox"][readonly] { .help-inline { display: inline-block; vertical-align: middle; - padding-left: 5px; + padding-top: 4px; + padding-left: 2px; } div.help { diff --git a/src/Umbraco.Web.UI.Client/src/less/gridview.less b/src/Umbraco.Web.UI.Client/src/less/gridview.less index bb684cd69b..238feead90 100644 --- a/src/Umbraco.Web.UI.Client/src/less/gridview.less +++ b/src/Umbraco.Web.UI.Client/src/less/gridview.less @@ -365,12 +365,12 @@ .usky-grid .iconBox.selected { -webkit-appearance: none; - background-image: -webkit-gradient(linear,0 0,0 100%,from(#e6e6e6),to(#bfbfbf)); - background-image: -webkit-linear-gradient(top,#e6e6e6,#bfbfbf); - background-image: linear-gradient(to bottom,#e6e6e6,#bfbfbf); + background-image: -webkit-gradient(linear,0 0,0 100%,from(@gray-9),to(@gray-7)); + background-image: -webkit-linear-gradient(top,@gray-9,@gray-7); + background-image: linear-gradient(to bottom,@gray-9,@gray-7); background-repeat: repeat-x; zoom: 1; - border-color: #bfbfbf #bfbfbf #999; + border-color: @gray-7 @gray-7 @gray-6; border-color: rgba(0,0,0,0.1) rgba(0,0,0,0.1) rgba(0,0,0,0.25); box-shadow: inset 0 2px 4px rgba(0,0,0,0.15),0 1px 2px rgba(0,0,0,0.05); border-radius: 3px; @@ -379,7 +379,7 @@ .usky-grid .iconBox i { font-size:16px !important; - color: #5F5F5F; + color: @gray-4; display:block; } diff --git a/src/Umbraco.Web.UI.Client/src/less/hacks.less b/src/Umbraco.Web.UI.Client/src/less/hacks.less index 0b6d1b7a60..8d3117febe 100644 --- a/src/Umbraco.Web.UI.Client/src/less/hacks.less +++ b/src/Umbraco.Web.UI.Client/src/less/hacks.less @@ -111,11 +111,10 @@ iframe, .content-column-body { } .pa-select-type label { - padding: 0 20px; + padding: 0 15px; } .pa-access-header { - font-weight: bold; margin: 0 0 3px 0; padding-bottom: 0; } @@ -186,40 +185,38 @@ iframe, .content-column-body { // Inline code // 1: Revert border radius to match look and feel of 7.4+ -code{ - .border-radius(@baseBorderRadius); // 1 +code { + .border-radius(@baseBorderRadius); // 1 } // Blocks of code // 1: Wrapping code is unreadable on small devices. pre { - display: block; - padding: (@baseLineHeight - 1) / 2; - margin: 0 0 @baseLineHeight / 2; - font-family: @sansFontFamily; - //font-size: @baseFontSize - 1; // 14px to 13px - color: @gray-2; - line-height: @baseLineHeight; - white-space: pre-wrap; // 1 - overflow-x: auto; // 1 - background-color: @gray-10; - border: 1px solid @gray-8; - .border-radius(@baseBorderRadius); + display: block; + padding: (@baseLineHeight - 1) / 2; + margin: 0 0 @baseLineHeight / 2; + font-family: @sansFontFamily; + color: @gray-2; + line-height: @baseLineHeight; + white-space: pre-wrap; // 1 + overflow-x: auto; // 1 + background-color: @brownGrayLight; + border: 1px solid @gray-8; + .border-radius(@baseBorderRadius); + // Make prettyprint styles more spaced out for readability + &.prettyprint { + margin-bottom: @baseLineHeight; + } - // Make prettyprint styles more spaced out for readability - &.prettyprint { - margin-bottom: @baseLineHeight; - } - - // Account for some code outputs that place code tags in pre tags - code { - padding: 0; - white-space: pre; // 1 - word-wrap: normal; // 1 - background-color: transparent; - border: 0; - } + // Account for some code outputs that place code tags in pre tags + code { + padding: 0; + white-space: pre; // 1 + word-wrap: normal; // 1 + background-color: transparent; + border: 0; + } } /* Styling for content/media sort order dialog */ diff --git a/src/Umbraco.Web.UI.Client/src/less/installer.less b/src/Umbraco.Web.UI.Client/src/less/installer.less index 798252c394..4e24161e59 100644 --- a/src/Umbraco.Web.UI.Client/src/less/installer.less +++ b/src/Umbraco.Web.UI.Client/src/less/installer.less @@ -1,7 +1,9 @@ -// Core variables and mixins +// Core variables and mixins @import "fonts.less"; // Loading fonts @import "variables.less"; // Modify this for custom colors, font-sizes, etc +@import "colors.less"; @import "mixins.less"; +@import "application/umb-outline.less"; @import "buttons.less"; @import "forms.less"; @@ -13,241 +15,245 @@ @import "../../lib/bootstrap/less/thumbnails.less"; @import "../../lib/bootstrap/less/media.less"; +// Umbraco Components +@import "components/umb-loader.less"; + [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { - display: none !important; + display: none !important; } html { - background: url('../img/installer.jpg') no-repeat center center fixed; - background-size: cover; + background: url('../img/installer.jpg') no-repeat center center fixed; + background-size: cover; } body { - margin: 0; - padding: 0; - height: 100%; - width: 100%; - - font-family: @baseFontFamily; - font-size: @baseFontSize; - line-height: @baseLineHeight; - color: @textColor; - - vertical-align: middle; - text-align: center; - - // better font rendering - -webkit-font-smoothing: antialiased; - font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - + margin: 0; + padding: 0; + height: 100%; + width: 100%; + font-family: @baseFontFamily; + font-size: @baseFontSize; + line-height: @baseLineHeight; + color: @textColor; + vertical-align: middle; + text-align: center; + // better font rendering + -webkit-font-smoothing: antialiased; + font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -#logo{ - position: absolute; - top: 20px; - left: 20px; - opacity: 0.8; - z-index: 777; +#logo { + position: absolute; + top: 20px; + left: 20px; + opacity: 0.8; + z-index: 777; } -#installer{ - margin: auto; +#installer { + margin: auto; background: @white; - width: 750px; - height: 600px; - text-align: left; - padding: 30px; - overflow:hidden; - z-index: 667; + width: 750px; + height: 600px; + text-align: left; + padding: 30px; + overflow: hidden; + z-index: 667; } -#overlay{ - position: absolute; - top: 0; - right: 0; - left: 0; - bottom: 0; - background: @purple-d2; - z-index: 666; +#overlay { + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + background: @purple-d2; + z-index: 666; } -.loading #overlay{ +.loading #overlay { opacity: 0.5; display: block !important; } -#fact{ - color: @white; - text-shadow: 0px 0px 4px @black; - font-size: 25px; - text-align: left; - line-height: 35px; - z-index: 667; - height: 600px; - width: 750px; +#fact { + color: @white; + text-shadow: 0px 0px 4px @black; + font-size: 25px; + text-align: left; + line-height: 35px; + z-index: 667; + height: 600px; + width: 750px; + + h2 { + font-size: 35px; + border-bottom: 1px solid @white; + padding-bottom: 10px; + margin-bottom: 20px; + color: @white; + } + + a { + color: @white; + } } -#fact h2{ - font-size: 35px; - border-bottom: 1px solid @white; - padding-bottom: 10px; - margin-bottom: 20px; - color: @white; -} - -#fact a{color: @white;} - -#feedback{ - color: @white; - text-shadow: 0px 0px 4px @black; - font-size: 14px; - text-align: center; - line-height: 20px; - z-index: 667; - bottom: 20px; - right: 0; - left: 0; - height: 25px; - position: absolute; +#feedback { + color: @white; + text-shadow: 0px 0px 4px @black; + font-size: 14px; + text-align: center; + line-height: 20px; + z-index: 667; + bottom: 20px; + right: 0; + left: 0; + height: 25px; + position: absolute; } -h1{ - border-bottom: 1px solid @gray-10; - padding-bottom: 10px; - color: @gray-2; +h1 { + border-bottom: 1px solid @gray-10; + padding-bottom: 10px; + color: @gray-2; } -.error h1, .error .message, span.error{ color: @red;} +.error h1, .error .message, span.error { + color: @red; +} -legend{font-size: 14px; font-weight: bold} +legend { + font-size: 14px; + font-weight: bold +} -input.ng-dirty.ng-invalid{border-color: #b94a48; color: #b94a48;} -.disabled{ - opacity: 0.6; +input.ng-dirty.ng-invalid { + border-color: @pink; + color: @pink; +} + +.disabled { + opacity: 0.6; } -#installer label.control-label, +#installer label.control-label, #installer .constrol-label { padding-top: 5px !important; } -.controls{ - text-align: left -} +.controls { + text-align: left; -.controls small{display: block; color: @gray-3;} + small { + display: block; + color: @gray-3; + } +} .absolute-center { - margin: auto; - position: absolute; - top: 0; left: 0; bottom: 0; right: 0; + margin: auto; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; } -.fade-hide, .fade-show { - transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; -} -.fade-hide { - opacity:1; -} -.fade-hide.fade-hide-active { - opacity:0; -} +.fade-hide, .fade-show { - opacity:0; + transition: all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s; } + +.fade-hide { + opacity: 1; +} + +.fade-hide.fade-hide-active { + opacity: 0; +} + +.fade-show { + opacity: 0; +} + .fade-show.fade-show-active { - opacity:1; + opacity: 1; } +.umb-installer-loader { + margin: 0; + width: 0; + z-index: 777; -.umb-loader{ - background-color: @white; - margin-top:0; - margin-left:-100%; - animation-name:bounce_loadingProgressG; - animation-duration:1s; - animation-iteration-count:infinite; - animation-timing-function:linear; - width:100%; - height: 5px; -} - -@keyframes bounce_loadingProgressG{ - 0%{ - margin-left:-100%; - } - 100%{ - margin-left:100%; + .umb-loader { + background-color: @white; + height: 5px; } } -//loader defaults -.umb-loader-container, .umb-loader-done{ - height: 3px; - position: absolute; - bottom: 0; - left: 0; - overflow: hidden; - width: 0%; - z-index: 777; -} - -.umb-loader-done{ - right: 0%; - background: @white; -} - - .permissions-report { - overflow:auto; - height:320px; - margin:0; - display:block; - padding:0; + overflow: auto; + height: 320px; + margin: 0; + display: block; + padding: 0; } + .permissions-report > li { - list-style:none; + list-style: none; } + .permissions-report h4 { - margin:7px; + margin: 7px; } .upgrade-report { - overflow:auto; - height:280px; - display:block; + overflow: auto; + height: 280px; + display: block; } select { - width:320px; + width: 320px; } #ysod { - height:500px; - width:100%; - overflow:auto; - border:none; + height: 500px; + width: 100%; + overflow: auto; + border: none; } -#starterKits .thumbnails{ - min-height:128px; -} -#starterKits a.thumbnail { - position:relative; - height:98px; -} -#starterKits a.thumbnail small { - position:absolute; - z-index: 50; - top:10px; - left:10px; -} -#starterKits a.thumbnail img { - position:relative; - z-index:100; + +#starterKits { + .thumbnails { + min-height: 128px; + padding-left: 0; + } + + .thumbnail { + position: relative; + cursor: pointer; + + small { + padding: 10px 10px 5px; + display: inline-block; + } + + img { + position: relative; + z-index: 100; + } + } + + .btn-link { + padding-left: 0; + } } diff --git a/src/Umbraco.Web.UI.Client/src/less/legacydialog.less b/src/Umbraco.Web.UI.Client/src/less/legacydialog.less index ca920d22c2..f1835a682c 100644 --- a/src/Umbraco.Web.UI.Client/src/less/legacydialog.less +++ b/src/Umbraco.Web.UI.Client/src/less/legacydialog.less @@ -16,8 +16,8 @@ margin-top: 10px; height: 100%; overflow: auto; - border-top: 1px solid #ccc; - border-top: 1px solid #ccc; + border-top: 1px solid @gray-8; + border-top: 1px solid @gray-8; padding: 5px; } @@ -30,11 +30,11 @@ .umb-dialog .diff table th { padding: 5px; width: 25%; - border-bottom: 1px solid #ccc; + border-bottom: 1px solid @gray-8; } .umb-dialog .diff table td { - border-bottom: 1px solid #ccc; + border-bottom: 1px solid @gray-8; padding: 3px; } diff --git a/src/Umbraco.Web.UI.Client/src/less/listview.less b/src/Umbraco.Web.UI.Client/src/less/listview.less index 9bd582a8ad..fe8af6dbc4 100644 --- a/src/Umbraco.Web.UI.Client/src/less/listview.less +++ b/src/Umbraco.Web.UI.Client/src/less/listview.less @@ -1,6 +1,10 @@ // Listview // ------------------------- +.umb-listview { + min-height: 100px; +} + .umb-listview table { border: 1px solid @gray-8; } @@ -43,6 +47,15 @@ /* add padding */ .left-addon input[type="text"] { padding-left: 30px !important; padding-right: 6px; } .right-addon input[type="text"] { padding-right: 30px; padding-left: 6px !important; } + + &__label-icon{ + width: 30px; + height: 30px; + position: absolute; + top: -1px; + left:0; + margin:0 + } } .umb-listview table form { @@ -136,7 +149,36 @@ /* TEMP */ .umb-minilistview { - .umb-table-row.not-allowed { opacity: 0.6; cursor: not-allowed; } + .umb-table-row.not-allowed { + opacity: 0.6; + cursor: not-allowed; + } + + div.umb-mini-list-view__breadcrumb { + margin-bottom: 10px; + } + + div.no-display { + display: none + } + + div.umb-table-cell-padding { + padding-top: 8px; + padding-bottom: 8px; + } + + div.umb-table-cell .form-search { + width: 100%; + margin-right: 0; + + input { + width: 100%; + } + + .icon-search { + font-size: 14px; + } + } } .umb-listview .table-striped tbody td { @@ -203,9 +245,9 @@ .list-view-layout__name-text { margin-right: 3px; } - + .list-view-layout__system { - font-size: 10px; + font-size: 10px; font-weight: normal; } @@ -236,6 +278,8 @@ } .list-view-add-layout { + width:100%; + background:0 0; margin-top: 10px; color: @ui-action-discreet-type; border: 1px dashed @ui-action-discreet-border; diff --git a/src/Umbraco.Web.UI.Client/src/less/main.less b/src/Umbraco.Web.UI.Client/src/less/main.less index 9f9bbce310..b34f313435 100644 --- a/src/Umbraco.Web.UI.Client/src/less/main.less +++ b/src/Umbraco.Web.UI.Client/src/less/main.less @@ -116,24 +116,48 @@ h5.-black { margin: 20px; } .umb-control-group { - border-bottom: 1px solid @gray-11; - padding-bottom: 20px; + position: relative; + + &.umb-control-group__listview { + // position: relative messes up the listview status messages (e.g. "no search results") + position: unset; + } + + &::after { + content: ''; + display:block; + margin-top: 20px; + width: 100%; + height: 1px; + background-color: @gray-11; + } } .umb-control-group.-no-border { - border: none; + &::after { + margin-top: 0px; + height: 0; + background-color: transparent; + } } .umb-property:last-of-type .umb-control-group { - border: none; - margin-bottom: 0 !important; - padding-bottom: 0; + &::after { + margin-top: 0px; + height: 0; + background-color: transparent; + } + margin-bottom: 0 !important; } /* BLOCK MODE */ .block-form .umb-control-group { - border-bottom: none; - padding-bottom: 0; + margin-top: 0px; + &::after { + margin-top: 0px; + height: 0; + background-color: transparent; + } } .block-form .umb-control-group label .help-block, @@ -163,7 +187,44 @@ h5.-black { } .umb-control-group .umb-el-wrap { - padding: 0 + padding: 0; +} +.umb-control-group .control-header { + + .control-label { + float: left; + } + + .control-description { + display: block; + clear: both; + } +} +.form-horizontal .umb-control-group .control-header { + float: left; + width: 160px; + padding-top: 5px; + text-align: left; + + .control-label { + width: auto; + padding-top: 0; + text-align: left; + } + + .control-description { + max-width:480px;// avoiding description becoming too wide when its placed on top of property. + margin-bottom: 10px; + } +} +@media (max-width: 767px) { + + .form-horizontal .umb-control-group .control-header { + float: none; + width: 100%; + } + + } /* LABELS*/ @@ -486,42 +547,6 @@ table thead a { color:@green; } -// Loading Animation -// ------------------------ - -.umb-loader{ - background-color: @blue; - margin-top:0; - margin-left:-100%; - animation-name:bounce_loadingProgressG; - animation-duration:1s; - animation-iteration-count:infinite; - animation-timing-function:linear; - width:100%; - height:2px; -} - -@keyframes bounce_loadingProgressG{ - 0%{ - margin-left:-100%; - } - 100%{ - margin-left:100%; - } -} - -.umb-loader-wrapper { - position: absolute; - right: 0; - left: 0; - margin: 10px 0; - overflow: hidden; -} - -.umb-loader-wrapper.-bottom { - bottom: 0; -} - // Helpers .strong { @@ -592,13 +617,3 @@ input[type=checkbox]:checked + .input-label--small { background-color: @green-l3; text-decoration: none; } - -.visuallyhidden{ - position: absolute !important; - clip: rect(1px, 1px, 1px, 1px); - padding:0 !important; - border:0 !important; - height: 1px !important; - width: 1px !important; - overflow: hidden; -} diff --git a/src/Umbraco.Web.UI.Client/src/less/mixins.less b/src/Umbraco.Web.UI.Client/src/less/mixins.less index ce35097658..efc0178ca2 100644 --- a/src/Umbraco.Web.UI.Client/src/less/mixins.less +++ b/src/Umbraco.Web.UI.Client/src/less/mixins.less @@ -30,7 +30,6 @@ outline: thin dotted @gray-3; // Webkit outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px; } // Center-align a block level element @@ -308,7 +307,14 @@ opacity: @opacity / 100; } - +// Position +.absolute() { + position:absolute; + top:0; + right:0; + bottom:0; + left:0; +} // BACKGROUNDS // -------------------------------------------------- @@ -325,7 +331,7 @@ } // Gradient Bar Colors for buttons and alerts -.gradientBar(@primaryColor, @secondaryColor, @textColor: #fff) { +.gradientBar(@primaryColor, @secondaryColor, @textColor: @white) { color: @textColor; #gradient > .vertical(@primaryColor, @secondaryColor); border-color: @secondaryColor @secondaryColor darken(@secondaryColor, 15%); @@ -334,7 +340,7 @@ // Gradients #gradient { - .horizontal(@startColor: #555, @endColor: #333) { + .horizontal(@startColor: @gray, @endColor: @grayDark) { background-color: @endColor; background-image: -moz-linear-gradient(left, @startColor, @endColor); // FF 3.6+ background-image: -webkit-gradient(linear, 0 0, 100% 0, from(@startColor), to(@endColor)); // Safari 4+, Chrome 2+ @@ -343,7 +349,7 @@ background-image: linear-gradient(to right, @startColor, @endColor); // Standard, IE10 background-repeat: repeat-x; } - .vertical(@startColor: #555, @endColor: #333) { + .vertical(@startColor: @gray, @endColor: @grayDark) { background-color: mix(@startColor, @endColor, 60%); background-image: -moz-linear-gradient(top, @startColor, @endColor); // FF 3.6+ background-image: -webkit-gradient(linear, 0 0, 0 100%, from(@startColor), to(@endColor)); // Safari 4+, Chrome 2+ @@ -352,7 +358,7 @@ background-image: linear-gradient(to bottom, @startColor, @endColor); // Standard, IE10 background-repeat: repeat-x; } - .directional(@startColor: #555, @endColor: #333, @deg: 45deg) { + .directional(@startColor: @gray, @endColor: @grayDark, @deg: 45deg) { background-color: @endColor; background-repeat: repeat-x; background-image: -moz-linear-gradient(@deg, @startColor, @endColor); // FF 3.6+ @@ -360,7 +366,7 @@ background-image: -o-linear-gradient(@deg, @startColor, @endColor); // Opera 11.10 background-image: linear-gradient(@deg, @startColor, @endColor); // Standard, IE10 } - .horizontal-three-colors(@startColor: #00b3ee, @midColor: #7a43b6, @colorStop: 50%, @endColor: #c3325f) { + .horizontal-three-colors(@startColor: @lightBlueIcon, @midColor: @purple-l1, @colorStop: 50%, @endColor: @pink) { background-color: mix(@midColor, @endColor, 80%); background-image: -webkit-gradient(left, linear, 0 0, 0 100%, from(@startColor), color-stop(@colorStop, @midColor), to(@endColor)); background-image: -webkit-linear-gradient(left, @startColor, @midColor @colorStop, @endColor); @@ -370,7 +376,7 @@ background-repeat: no-repeat; } - .vertical-three-colors(@startColor: #00b3ee, @midColor: #7a43b6, @colorStop: 50%, @endColor: #c3325f) { + .vertical-three-colors(@startColor: @lightBlueIcon, @midColor: @deepPurpleIcon, @colorStop: 50%, @endColor: @pink) { background-color: mix(@midColor, @endColor, 80%); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(@startColor), color-stop(@colorStop, @midColor), to(@endColor)); background-image: -webkit-linear-gradient(@startColor, @midColor @colorStop, @endColor); @@ -379,7 +385,7 @@ background-image: linear-gradient(@startColor, @midColor @colorStop, @endColor); background-repeat: no-repeat; } - .radial(@innerColor: #555, @outerColor: #333) { + .radial(@innerColor: @gray, @outerColor: @grayDark) { background-color: @outerColor; background-image: -webkit-gradient(radial, center center, 0, center center, 460, from(@innerColor), to(@outerColor)); background-image: -webkit-radial-gradient(circle, @innerColor, @outerColor); @@ -387,7 +393,7 @@ background-image: -o-radial-gradient(circle, @innerColor, @outerColor); background-repeat: no-repeat; } - .striped(@color: #555, @angle: 45deg) { + .striped(@color: @gray, @angle: 45deg) { background-color: @color; background-image: -webkit-gradient(linear, 0 100%, 100% 0, color-stop(.25, rgba(255,255,255,.15)), color-stop(.25, transparent), color-stop(.5, transparent), color-stop(.5, rgba(255,255,255,.15)), color-stop(.75, rgba(255,255,255,.15)), color-stop(.75, transparent), to(transparent)); background-image: -webkit-linear-gradient(@angle, rgba(255,255,255,.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,.15) 50%, rgba(255,255,255,.15) 75%, transparent 75%, transparent); @@ -397,11 +403,19 @@ } } - +.checkeredBackground(@backgroundColor: @gray-9, @fillColor: @black, @fillOpacity: 0.25) { + background-image: url('data:image/svg+xml;charset=utf-8,\ + \ + \ + \ + '); + background-color: @backgroundColor; + background-size: 10px 10px; + background-repeat: repeat; +} // COMPONENT MIXINS // -------------------------------------------------- - // Limit width of specific property editors .umb-property-editor--limit-width { max-width: 800px; @@ -420,28 +434,28 @@ // Button backgrounds // ------------------ -.buttonBackground(@startColor, @hoverColor: @startColor, @textColor: #fff, @textColorHover: @textColor) { - +.buttonBackground(@startColor, @hoverColor: @startColor, @textColor: @white, @textColorHover: @textColor, @disabledColor: @sand-1, @disabledTextColor: @white) { + color: @textColor; border-color: @startColor @startColor darken(@startColor, 15%); border-color: rgba(0,0,0,.1) rgba(0,0,0,.1) fadein(rgba(0,0,0,.1), 15%); - + background-color: @startColor; - + .caret { border-top-color: @textColor; border-bottom-color: @textColor; } // in these cases the gradient won't cover the background, so we override - &:hover, &:focus, &:active, &.active { + &:hover { color: @textColorHover; background-color: @hoverColor; } - + &.disabled, &[disabled] { - color: @white; - background-color: @sand-1; + background-color: @disabledColor; + color: @disabledTextColor; } } diff --git a/src/Umbraco.Web.UI.Client/src/less/modals.less b/src/Umbraco.Web.UI.Client/src/less/modals.less index 84de751b12..925f845c4c 100644 --- a/src/Umbraco.Web.UI.Client/src/less/modals.less +++ b/src/Umbraco.Web.UI.Client/src/less/modals.less @@ -85,7 +85,7 @@ bottom: 0px; position: absolute; padding: 0px; - background: #fff; + background: @white; } .umb-dialog .umb-btn-toolbar .umb-control-group{ @@ -99,7 +99,7 @@ top: 0px; left: 0px; right: 0px; - bottom: 52px; + bottom: 49px; } .umb-dialog-body .umb-pane{margin-top: 15px;} @@ -111,7 +111,7 @@ left: 0px; right: 0px; bottom: 0px; - padding: 20px; + padding: 8px; margin: 0; .btn.umb-outline { diff --git a/src/Umbraco.Web.UI.Client/src/less/navs.less b/src/Umbraco.Web.UI.Client/src/less/navs.less index a2710fab6c..c347404619 100644 --- a/src/Umbraco.Web.UI.Client/src/less/navs.less +++ b/src/Umbraco.Web.UI.Client/src/less/navs.less @@ -233,11 +233,32 @@ } .dropdown-menu > li > a { + position: relative; padding: 8px 20px; color: @ui-option-type; + text-decoration: none; } -.dropdown-menu > li > a:hover, .dropdown-menu > li > a:focus, .dropdown-submenu:hover > a, .dropdown-submenu:focus > a { +.dropdown-menu > li > button { + position: relative; + background: transparent; + border: 0; + padding: 8px 20px; + color: @ui-option-type; + display: block; + clear: both; + font-weight: normal; + line-height: 20px; + white-space: nowrap; + cursor:pointer; + width: 100%; + text-align: left; +} + +.dropdown-menu > li > a:hover, +.dropdown-menu > li > button:hover, +.dropdown-submenu:hover > a, +.dropdown-submenu:hover > button { color: @ui-option-type-hover; background: @ui-option-hover; } @@ -280,8 +301,7 @@ // Active:hover/:focus dropdown links // ------------------------- -.nav > .dropdown.active > a:hover, -.nav > .dropdown.active > a:focus { +.nav > .dropdown.active > a:hover { cursor: pointer; } @@ -289,24 +309,21 @@ // ------------------------- .nav-tabs .open .dropdown-toggle, .nav-pills .open .dropdown-toggle, -.nav > li.dropdown.open.active > a:hover, -.nav > li.dropdown.open.active > a:focus { +.nav > li.dropdown.open.active > a:hover { /*color: @white;*/ background-color: @gray-8; border-color: @gray-8; } .nav li.dropdown.open .caret, .nav li.dropdown.open.active .caret, -.nav li.dropdown.open a:hover .caret, -.nav li.dropdown.open a:focus .caret { +.nav li.dropdown.open a:hover .caret { border-top-color: @white; border-bottom-color: @white; .opacity(100); } // Dropdowns in stacked tabs -.tabs-stacked .open > a:hover, -.tabs-stacked .open > a:focus { +.tabs-stacked .open > a:hover { border-color: @gray-8; } @@ -357,15 +374,13 @@ } .tabs-below > .nav-tabs > li > a { .border-radius(0 0 4px 4px); - &:hover, - &:focus { + &:hover { border-bottom-color: transparent; border-top-color: @gray-8; } } .tabs-below > .nav-tabs > .active > a, -.tabs-below > .nav-tabs > .active > a:hover, -.tabs-below > .nav-tabs > .active > a:focus { +.tabs-below > .nav-tabs > .active > a:hover { border-color: transparent @gray-8 @gray-8 @gray-8; } @@ -394,13 +409,11 @@ margin-right: -1px; .border-radius(4px 0 0 4px); } -.tabs-left > .nav-tabs > li > a:hover, -.tabs-left > .nav-tabs > li > a:focus { +.tabs-left > .nav-tabs > li > a:hover { border-color: @gray-10 @gray-8 @gray-10 @gray-10; } .tabs-left > .nav-tabs .active > a, -.tabs-left > .nav-tabs .active > a:hover, -.tabs-left > .nav-tabs .active > a:focus { +.tabs-left > .nav-tabs .active > a:hover { border-color: @gray-8 transparent @gray-8 @gray-8; *border-right-color: @white; } @@ -415,13 +428,11 @@ margin-left: -1px; .border-radius(0 4px 4px 0); } -.tabs-right > .nav-tabs > li > a:hover, -.tabs-right > .nav-tabs > li > a:focus { +.tabs-right > .nav-tabs > li > a:hover { border-color: @gray-10 @gray-10 @gray-10 @gray-8; } .tabs-right > .nav-tabs .active > a, -.tabs-right > .nav-tabs .active > a:hover, -.tabs-right > .nav-tabs .active > a:focus { +.tabs-right > .nav-tabs .active > a:hover { border-color: @gray-8 @gray-8 @gray-8 transparent; *border-left-color: @white; } @@ -436,8 +447,7 @@ color: @gray-8; } // Nuke hover/focus effects -.nav > .disabled > a:hover, -.nav > .disabled > a:focus { +.nav > .disabled > a:hover { text-decoration: none; background-color: transparent; cursor: default; diff --git a/src/Umbraco.Web.UI.Client/src/less/pages/document-type-editor.less b/src/Umbraco.Web.UI.Client/src/less/pages/document-type-editor.less deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/Umbraco.Web.UI.Client/src/less/pages/nonodes.less b/src/Umbraco.Web.UI.Client/src/less/pages/nonodes.less index 868a358d21..7c493ab18e 100644 --- a/src/Umbraco.Web.UI.Client/src/less/pages/nonodes.less +++ b/src/Umbraco.Web.UI.Client/src/less/pages/nonodes.less @@ -1,5 +1,6 @@ @import "../fonts.less"; // Loading fonts @import "../variables.less"; // Loading variables +@import "../colors.less"; // Loading colors that were in variables abbr, address, diff --git a/src/Umbraco.Web.UI.Client/src/less/panel.less b/src/Umbraco.Web.UI.Client/src/less/panel.less index bad0ab9715..40c70f5331 100644 --- a/src/Umbraco.Web.UI.Client/src/less/panel.less +++ b/src/Umbraco.Web.UI.Client/src/less/panel.less @@ -341,6 +341,7 @@ .umb-panel-header-icon { cursor: pointer; margin-right: 5px; + margin-top: -6px; height: 50px; display: flex; justify-content: center; diff --git a/src/Umbraco.Web.UI.Client/src/less/properties.less b/src/Umbraco.Web.UI.Client/src/less/properties.less index e14bb5c0d6..8523fe9300 100644 --- a/src/Umbraco.Web.UI.Client/src/less/properties.less +++ b/src/Umbraco.Web.UI.Client/src/less/properties.less @@ -48,6 +48,10 @@ flex-direction: row; } +.date-wrapper-mini--checkbox{ + margin: 0 0 0 26px; +} + .date-wrapper-mini__date { display: flex; @@ -93,7 +97,8 @@ .history-line { width: 2px; - height: 100%; + top: 10px; + bottom: 10px; margin: 0 0 0 14px; background-color: @gray-8; position: absolute; diff --git a/src/Umbraco.Web.UI.Client/src/less/property-editors.less b/src/Umbraco.Web.UI.Client/src/less/property-editors.less index 92351d09ca..112f94572d 100644 --- a/src/Umbraco.Web.UI.Client/src/less/property-editors.less +++ b/src/Umbraco.Web.UI.Client/src/less/property-editors.less @@ -1,16 +1,13 @@ -@checkered-background: url(../img/checkered-background.png); - // // Container styles // -------------------------------------------------- .umb-property-editor { width: 100%; - position:relative; } .umb-property-editor-tiny { width: 60px; - + &.umb-editor-push { width:30%; min-width:0; @@ -33,7 +30,7 @@ } .umb-codeeditor{ - width: 99%; + width: 99%; } // displays property inline with preceeding @@ -142,7 +139,7 @@ margin-bottom: 0; vertical-align: middle; padding: 6px 10px; - background: #f7f7f7; + background: @gray-11; flex: 0 0 auto; } @@ -172,8 +169,8 @@ border: 1px solid @white; padding: 6px 10px; font-family: monospace; - border: 1px solid #dfdfe1; - background: #f7f7f7; + border: 1px solid @gray-8; + background: @gray-11; margin: 0 15px 0 3px; border-radius: 3px; } @@ -235,6 +232,7 @@ } .umb-mediapicker-multi > div { width:100%; + .umb-property-editor--limit-width(); } @@ -251,30 +249,15 @@ transition: all 150ms ease-in-out; - &:focus, + &:active, &:hover { color: @ui-action-discreet-type-hover; border-color: @ui-action-discreet-type-hover; } - - &:focus { - outline: none; - .tabbing-active &:after { - content: ''; - position: absolute; - z-index: 10000; - top: -6px; - bottom: -6px; - left: -6px; - right: -6px; - border-radius: 3px; - box-shadow: 0 0 2px @blueMid, inset 0 0 2px 1px @blueMid; - } - } } -.umb-mediapicker .label{ - &__trashed{ +.umb-mediapicker .label { + &__trashed { background-color: @red; position: absolute; top: 50%; @@ -282,6 +265,7 @@ z-index: 1; transform: translate3d(-50%,-50%,0); margin: 0; + pointer-events: none; } } @@ -357,25 +341,19 @@ padding: 5px; margin: 5px; background: @white; - //border: 1px solid @gray-10; max-width: 100%; } .umb-mediapicker { - + .umb-sortable-thumbnails li { border:none; } - + } - - .umb-mediapicker .umb-sortable-thumbnails li { flex-direction: column; } -/*.umb-mediapicker .umb-sortable-thumbnails li.add-wrapper { - padding: 0px; -}*/ .umb-sortable-thumbnails li:hover a { display: flex; @@ -384,11 +362,11 @@ } .umb-sortable-thumbnails li img { - max-width:100%; - max-height:100%; - margin:auto; - display:block; - background-image: @checkered-background; + max-width: 100%; + max-height: 100%; + margin: auto; + display: block; + .checkeredBackground(); } .umb-sortable-thumbnails li .trashed { @@ -400,34 +378,6 @@ max-height: none !important; } -.umb-sortable-thumbnails .umb-icon-holder { - text-align: center; -} - -.umb-sortable-thumbnails .umb-icon-holder .icon { - font-size: 40px; - line-height: 50px; - color: @gray-3; - display: block; -} - -.umb-sortable-thumbnails .umb-icon-holder .file-icon > span { - color: @white; - background: @gray-4; - padding: 1px 3px; - font-size: 10px; - line-height: 130%; - display: block; - margin-top: -30px; - width: 2em; -} - -.umb-sortable-thumbnails .umb-icon-holder .file-icon + small { - display: block; - margin-top: 1em; -} - - .umb-sortable-thumbnails .umb-sortable-thumbnails__wrapper { width: 124px; height: 124px; @@ -451,8 +401,6 @@ text-decoration: none; display: flex; flex-direction: row; - opacity: 0; - visibility: hidden; } .umb-sortable-thumbnails.ui-sortable:not(.ui-sortable-disabled) { @@ -461,9 +409,8 @@ } } -.umb-sortable-thumbnails li:hover .umb-sortable-thumbnails__actions { +.umb-sortable-thumbnails li:hover .umb-sortable-thumbnails__action { opacity: 1; - visibility: visible; } .umb-sortable-thumbnails .umb-sortable-thumbnails__action { @@ -478,9 +425,13 @@ align-items: center; margin-left: 5px; text-decoration: none; - - //border-color: @inputBorder; .box-shadow(0 1px 2px rgba(0,0,0,0.25)); + opacity: 0; + transition: opacity .1s ease-in-out; + + .tabbing-active &:focus { + opacity: 1; + } } .umb-sortable-thumbnails .umb-sortable-thumbnails__action.-red { @@ -619,7 +570,19 @@ .viewport { max-width: 600px; - background: @checkered-background; + .checkeredBackground(); + + img { + display: block; + margin-left: auto; + margin-right: auto; + } + + img { + display: block; + margin-left: auto; + margin-right: auto; + } &:hover { cursor: pointer; @@ -639,14 +602,14 @@ } .viewport img { - background: @checkered-background; + .checkeredBackground(); } } .imagecropper .umb-cropper__container { position: relative; margin-bottom: 10px; - max-width: 100%; + max-width: 100%; border: 1px solid @gray-10; @media (min-width: 769px) { @@ -758,13 +721,13 @@ } .umb-fileupload ul { - list-style: none; - vertical-align: middle; - margin-bottom: 0; + list-style: none; + vertical-align: middle; + margin-bottom: 0; - img { - background: @checkered-background; - } + img { + .checkeredBackground(); + } } .umb-fileupload label { @@ -783,30 +746,6 @@ padding-top: 27px; } -.umb-fileupload .file-icon { - display: inline-block; - position: relative; - padding: 5px 0; - - > .icon { - font-size: 70px; - line-height: 110%; - color: @gray-4; - text-align: center; - } - - > span { - color: @white; - background: @gray-4; - padding: 1px 3px; - font-size: 12px; - line-height: 130%; - position: absolute; - top: 45px; - left: 10px; - } -} - .umb-fileupload input { font-size: 12px; line-height: 1; @@ -869,7 +808,7 @@ background: @blueExtraDark; position: relative; user-select: all; - + .icon-trash { position: relative; cursor: pointer; @@ -890,7 +829,7 @@ border: none; background: @white; } - + .twitter-typeahead { margin: 10px; margin-top: 16px; @@ -927,6 +866,21 @@ .umb-datepicker p {margin-top:10px;} .umb-datepicker p a{color: @gray-3;} +// +// Link picker +// -------------------------------------------------- +.umb-linkpicker { + .umb-linkpicker__url { + width: 50%; + padding-right: 5px; + } + + .umb-linkpicker__anchor { + width: 50%; + padding-left: 5px; + } +} + // // Code mirror - even though this isn't a proprety editor right now, it could be so I'm putting the styles here // -------------------------------------------------- diff --git a/src/Umbraco.Web.UI.Client/src/less/rte-content.less b/src/Umbraco.Web.UI.Client/src/less/rte-content.less new file mode 100644 index 0000000000..3fe4e52f92 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/rte-content.less @@ -0,0 +1,45 @@ +@import "variables.less"; +@import "colors.less"; + +.mce-content-body .umb-macro-holder { + border: 3px dotted @pinkLight; + padding: 7px; + display: block; + margin: 3px; +} + + +.umb-rte .mce-content-body .umb-macro-holder.loading { + background: url(assets/img/loader.gif) right no-repeat; + background-size: 18px; background-position-x: 99%; +} + + +.umb-rte .embeditem { + position:relative; + > * { + user-select: none; + pointer-events: none; + } +} + +.umb-rte .embeditem[data-mce-selected] { + outline: 2px solid @pinkLight; +} + +.umb-rte .embeditem::before { + z-index:1000; + width:100%; + height:100%; + position:absolute; + content:' '; +} + +.umb-rte .embeditem[data-mce-selected]::before { + background:rgba(0,0,0,0.025); +} + +.umb-rte *[data-mce-selected="inline-boundary"] { + background:rgba(0,0,0,0.025); + outline: 2px solid @pinkLight; +} diff --git a/src/Umbraco.Web.UI.Client/src/less/rte.less b/src/Umbraco.Web.UI.Client/src/less/rte.less index 9f537a7931..d6d38f540a 100644 --- a/src/Umbraco.Web.UI.Client/src/less/rte.less +++ b/src/Umbraco.Web.UI.Client/src/less/rte.less @@ -3,18 +3,26 @@ .umb-rte { position: relative; - + .umb-property-editor--limit-width(); } .umb-rte .mce-tinymce { box-shadow: none; - } -.umb-rte .umb-rte-editor{ +.umb-rte .umb-rte-editor-con { + height: 24px; + visibility: hidden; +} +.umb-rte .umb-rte-editor { min-height: 100px; } +.umb-rte.--initialized .umb-rte-editor-con { + height:auto; + min-height: 100px; + visibility: visible; +} .umb-rte .mce-content-body { background-color: @white; @@ -48,12 +56,14 @@ } */ -.mce-ico { +.umb-rte .mce-ico { + text-align: center; font-size: 12px !important; /*color: @gray-1 !important;*/ } /* Special case to support helviticons for the tiny mce button controls */ +// Also used in Prevalue editor. .mce-ico.mce-i-custom[class^="icon-"], .mce-ico.mce-i-custom[class*=" icon-"] { font-family: icomoon; @@ -73,6 +83,85 @@ } } -.mce-fullscreen { - position:absolute; +.umb-rte .mce-toolbar .mce-btn-group { + padding: 0; +} + +.umb-rte .mce-btn { + color: @ui-action-type; + border-radius: 3px; +} + +.umb-rte .mce-btn-group .mce-btn { + margin-top:2px; + margin-bottom:2px; +} + +.umb-rte .mce-btn { + button:hover { + .mce-caret { + border-top-color: @ui-action-type-hover; + } + } +} + +.umb-rte .mce-btn:hover, .umb-rte .mce-btn:active { + background: @ui-action-hover; + border-color: transparent; + button { + color: @ui-action-type-hover; + .mce-ico { + color: @ui-action-type-hover; + } + } +} + +.umb-rte .mce-btn.mce-active, .umb-rte .mce-btn.mce-active:active, +.umb-rte .mce-btn.mce-active:hover, .umb-rte .mce-btn.mce-active:focus { + background: @gray-9; + border-color: transparent; + button { + color: @ui-action-type-hover; + .mce-ico { + color: @ui-action-type-hover; + } + .mce-caret { + border-top-color: @ui-action-type-hover; + } + } +} + + +.mce-menu { + border-radius: 3px; +} + +.mce-menu-item.mce-menu-item-normal.mce-stack-layout-item { + .mce-text, .mce-ico { + color: @ui-action-type; + } + &:hover { + background: @ui-action-hover; + .mce-text, .mce-ico { + color: @ui-action-type-hover; + } + } +} + +.mce-menu-item.mce-menu-item-normal.mce-stack-layout-item.mce-active { + &, &:hover { + background: @gray-9; + } + .mce-text, .mce-ico { + color: @ui-action-type-hover; + } +} + +.umb-grid .umb-rte { + border: 1px solid @gray-8; + max-width: none; +} + +.mce-fullscreen { + position: absolute; } diff --git a/src/Umbraco.Web.UI.Client/src/less/sections.less b/src/Umbraco.Web.UI.Client/src/less/sections.less index ef6c5f5046..40921c5b76 100644 --- a/src/Umbraco.Web.UI.Client/src/less/sections.less +++ b/src/Umbraco.Web.UI.Client/src/less/sections.less @@ -2,129 +2,143 @@ // ------------------------- ul.sections { - margin: 0; - display: flex; - margin-left: -20px; -} - -ul.sections>li { - display: flex; - justify-content: center; - align-items: center; - position: relative; -} - -ul.sections>li>a { - color: @white; - height: @appHeaderHeight; + margin: 0; display: flex; - align-items: center; - justify-content: center; - position: relative; - padding: 0 10px; - text-decoration: none; - outline: none; - cursor: pointer; -} + margin-left: -20px; -ul.sections>li>a .section__name { - border-radius: 3px; - margin-top:1px; - padding: 3px 10px 4px 10px; - opacity: 0.8; - transition: opacity .1s linear, box-shadow .1s; -} + > li { + display: flex; + justify-content: center; + align-items: center; + position: relative; -ul.sections>li>a::after { - content: ""; - left: 10px; - right: 10px; - height: 4px; - bottom: 0; - transform: translateY(4px); - background-color: @pinkLight; - position: absolute; - border-radius: 3px 3px 0 0; - opacity: 0; - padding: 0 2px; - transition: transform 240ms ease-in-out; -} + > a { + color: @white; + height: @appHeaderHeight; + display: flex; + align-items: center; + justify-content: center; + position: relative; + padding: 0 10px; + text-decoration: none; + outline: none; + cursor: pointer; -ul.sections>li.current>a { - color:@pinkLight; -} -ul.sections>li.current>a::after { - opacity: 1; - transform: translateY(0px); -} -ul.sections > li.current > a .section__name, -ul.sections > li > a:hover .section__name { - opacity: 1; - -webkit-font-smoothing: subpixel-antialiased; -} + &::after { + content: ""; + left: 10px; + right: 10px; + height: 4px; + bottom: 0; + transform: translateY(4px); + background-color: @ui-active; + position: absolute; + border-radius: 3px 3px 0 0; + opacity: 0; + padding: 0 2px; + transition: transform 240ms ease-in-out; + } -ul.sections > li > a:focus .section__name { - .tabbing-active & { - - border: 1px solid; - border-color: @gray-9; + &:focus .section__name { + .tabbing-active & { + border: 1px solid; + border-color: @gray-9; + } + } + } + + .section__name { + border-radius: 3px; + margin-top: 1px; + padding: 3px 10px 4px 10px; + opacity: 0.8; + transition: opacity .1s linear, box-shadow .1s; + } + + &.current a { + color: @ui-active; + + &::after { + opacity: 1; + transform: translateY(0px); + } + } + + &.expand { + i { + height: 5px; + width: 5px; + border-radius: 50%; + background: @white; + display: inline-block; + margin: 0 5px 0 0; + opacity: 0.6; + transition: opacity .1s linear; + } + + &:hover i { + opacity:1; + } + } + + &.current .section__name, + a:hover .section__name { + opacity: 1; + -webkit-font-smoothing: subpixel-antialiased; + } } } - - /* Sections tray */ -ul.sections>li.expand i { - height: 5px; - width: 5px; - border-radius: 50%; - background: #fff; - display: inline-block; - margin: 0 5px 0 0; - opacity: 0.6; -} - ul.sections-tray { - position: absolute; - top: @appHeaderHeight; - left: 0; - margin: 0; + position: absolute; + top: @appHeaderHeight; + left: 0; + margin: 0; list-style: none; - background: @purple; - z-index: 10000; - border-radius: 0 0 3px 3px; -} + background: @blueExtraDark; + z-index: 10000; + border-radius: 0 0 3px 3px; -ul.sections-tray>li>a { - padding: 8px 24px; - color: @white; - text-decoration: none; - display: block; - position: relative; -} + li { -ul.sections-tray>li>a::after { - content: ""; - width: 4px; - height: 100%; - background-color: @ui-active; - position: absolute; - border-radius: 0 3px 3px 0; - opacity: 0; - transition: all .2s linear; - top: 0; - left: 0; -} + &.current a { + color: @ui-active; + opacity: 1; -ul.sections-tray>li.current>a::after { - opacity: 1; -} + &::after { + opacity: 1; + } + } -ul.sections-tray>li>a .section__name { - opacity: 0.6; -} + a { + padding: 8px 24px; + color: @white; + text-decoration: none; + display: block; + position: relative; + outline: none; -ul.sections-tray>li>a:hover .section__name { - opacity: 1; + &::after { + content: ""; + width: 4px; + height: 100%; + background-color: @ui-active; + position: absolute; + border-radius: 0 3px 3px 0; + opacity: 0; + transition: all .2s linear; + top: 0; + left: 0; + } + + &:focus .section__name { + .tabbing-active & { + border: 1px solid; + border-color: @gray-9; + } + } + } + } } diff --git a/src/Umbraco.Web.UI.Client/src/less/tables.less b/src/Umbraco.Web.UI.Client/src/less/tables.less index cd6304ef49..2ecfa4d04e 100644 --- a/src/Umbraco.Web.UI.Client/src/less/tables.less +++ b/src/Umbraco.Web.UI.Client/src/less/tables.less @@ -62,7 +62,7 @@ table { } -.table tr > td:first-child { +.table:not(.table-bordered) tr > td:first-child { border-left: 4px solid transparent; } .table tr.--selected > td:first-child { @@ -263,3 +263,15 @@ table th[class*="span"], .table-sortable tbody tr { cursor: move; } + +.table__action-overlay{ + background: transparent; + border: 0 none; + padding: 0; + font-style: italic; + + &:focus, + &:hover{ + text-decoration: underline; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/less/utilities/_flexbox.less b/src/Umbraco.Web.UI.Client/src/less/utilities/_flexbox.less index c0815fa8ac..a3427074cd 100644 --- a/src/Umbraco.Web.UI.Client/src/less/utilities/_flexbox.less +++ b/src/Umbraco.Web.UI.Client/src/less/utilities/_flexbox.less @@ -1,7 +1,5 @@ /* - Flexbox - */ .flex { display: flex; } diff --git a/src/Umbraco.Web.UI.Client/src/less/utilities/_spacing.less b/src/Umbraco.Web.UI.Client/src/less/utilities/_spacing.less index b9c8b909e8..787e50f204 100644 --- a/src/Umbraco.Web.UI.Client/src/less/utilities/_spacing.less +++ b/src/Umbraco.Web.UI.Client/src/less/utilities/_spacing.less @@ -66,3 +66,19 @@ .ml5 { margin-left: @spacing-extra-large; } .ml6 { margin-left: @spacing-extra-extra-large; } .ml7 { margin-left: @spacing-extra-extra-extra-large; } + +.mr0 { margin-right: @spacing-none; } +.mr1 { margin-right: @spacing-extra-small; } +.mr2 { margin-right: @spacing-small; } +.mr3 { margin-right: @spacing-medium; } +.mr4 { margin-right: @spacing-large; } +.mr5 { margin-right: @spacing-extra-large; } +.mr6 { margin-right: @spacing-extra-extra-large; } +.mr7 { margin-right: @spacing-extra-extra-extra-large; } + +.p0 { padding: @spacing-none; } + +.pt0 { padding-top: @spacing-none; } +.pb0 { padding-bottom: @spacing-none; } +.pl0 { padding-left: @spacing-none; } +.pr0 { padding-right: @spacing-none; } diff --git a/src/Umbraco.Web.UI.Client/src/less/variables.less b/src/Umbraco.Web.UI.Client/src/less/variables.less index a1dc0ba187..a906bc0eed 100644 --- a/src/Umbraco.Web.UI.Client/src/less/variables.less +++ b/src/Umbraco.Web.UI.Client/src/less/variables.less @@ -76,11 +76,11 @@ @gray-10: #F3F3F5; @gray-11: #F6F6F7; -@sand-1: hsl(22, 18%, 84%);// added 2019 -@sand-2: hsl(22, 34%, 88%);// added 2019 -@sand-5: hsl(22, 31%, 93%);// added 2019 -@sand-6: hsl(22, 29%, 95%);// added 2019 -@sand-7: hsl(22, 26%, 97%);// added 2019 +@sand-1: #DED4CF;// added 2019 +@sand-2: #EBDED6;// added 2019 +@sand-5: #F3ECE8;// added 2019 +@sand-6: #F6F1EF;// added 2019 +@sand-7: #F9F7F5;// added 2019 // Additional Icon Colours @@ -108,6 +108,7 @@ //@blueLight: #4f89de; @blue: #2E8AEA; @blueMid: #2152A3;// updated 2019 +@blueMidLight: rgb(99, 174, 236); @blueDark: #3544b1;// updated 2019 @blueExtraDark: #1b264f;// added 2019 @blueLight: #ADD8E6; @@ -124,7 +125,7 @@ //@u-greyLight: #f2ebe6;// added 2019 @u-white: #f9f7f4;// added 2019 -@u-black: black;// added 2019 +@u-black: @black;// added 2019 // UI colors @@ -132,12 +133,16 @@ @ui-option-type: @blueExtraDark; @ui-option-type-hover: @blueMid; +@ui-option: @white; @ui-option-hover: @sand-7; @ui-option-disabled-type: @gray-6; @ui-option-disabled-type-hover: @gray-5; @ui-option-disabled-hover: @sand-7; +@ui-disabled-type: @gray-6; +@ui-disabled-border: @gray-6; + //@ui-active: #346ab3; @ui-active: @pinkLight; @ui-active-blur: @brownLight; @@ -148,8 +153,8 @@ @ui-selected-hover: ligthen(@sand-5, 10); @ui-selected-type: @blueExtraDark; @ui-selected-type-hover: @blueMid; -@ui-selected-border: @pinkLight; -@ui-selected-border-hover: darken(@pinkLight, 10); +@ui-selected-border: @blueDark; +@ui-selected-border-hover: darken(@blueDark, 10); @ui-light-border: @pinkLight; @ui-light-type: @gray-4; @@ -160,20 +165,22 @@ @ui-light-active-type-hover: @blueMid; -@ui-action: white; +@ui-action: @white; @ui-action-hover: @sand-7; @ui-action-type: @blueExtraDark; @ui-action-type-hover: @blueMid; @ui-action-border: @blueExtraDark; @ui-action-border-hover: @blueMid; -@ui-action-discreet: white; +@ui-action-discreet: @white; @ui-action-discreet-hover: @sand-7; @ui-action-discreet-type: @blueExtraDark; @ui-action-discreet-type-hover: @blueMid; @ui-action-discreet-border: @gray-7; @ui-action-discreet-border-hover: @blueMid; +@ui-outline: @blueMidLight; + @type-white: @white; @type-black: @blueNight; @@ -193,96 +200,11 @@ - -.red{color: @red;} -.blue{color: @blue;} -.black{color: @black;} -.turquoise{color: @turquoise;} -.turquoise-d1{color: @turquoise-d1;} - -.text-warning { - color: @orange; -} -.text-error { - color: @red; -} -.text-success { - color: @green; -} - - -//icon colors for tree icons -.color-red, .color-red i{color: @red-d1 !important;} -.color-blue, .color-blue i{color: @turquoise-d1 !important;} -.color-orange, .color-orange i{color: @orange !important;} -.color-green, .color-green i{color: @green-d1 !important;} -.color-yellow, .color-yellow i{color: @yellowIcon !important;} - -/* Colors based on https://zavoloklom.github.io/material-design-color-palette/colors.html */ -.btn-color-black {background-color: @black;} -.color-black i { color: @black;} - -.btn-color-blue-grey {background-color: @blueGrey;} -.color-blue-grey, .color-blue-grey i { color: @blueGrey !important;} - -.btn-color-grey{background-color: @grayIcon;} -.color-grey, .color-grey i { color: @grayIcon !important; } - -.btn-color-brown{background-color: @brownIcon;} -.color-brown, .color-brown i { color: @brownIcon !important; } - -.btn-color-blue{background-color: @blueIcon;} -.color-blue, .color-blue i { color: @blueIcon !important; } - -.btn-color-light-blue{background-color: @lightBlueIcon;} -.color-light-blue, .color-light-blue i {color: @lightBlueIcon !important;} - -.btn-color-cyan{background-color: @cyanIcon;} -.color-cyan, .color-cyan i { color: @cyanIcon !important; } - -.btn-color-green{background-color: @greenIcon;} -.color-green, .color-green i { color: @greenIcon !important; } - -.btn-color-light-green{background-color: @lightGreenIcon;} -.color-light-green, .color-light-green i {color: @lightGreenIcon !important; } - -.btn-color-lime{background-color: @limeIcon;} -.color-lime, .color-lime i { color: @limeIcon !important; } - -.btn-color-yellow{background-color: @yellowIcon;} -.color-yellow, .color-yellow i { color: @yellowIcon !important; } - -.btn-color-amber{background-color: @amberIcon;} -.color-amber, .color-amber i { color: @amberIcon !important; } - -.btn-color-orange{background-color: @orangeIcon;} -.color-orange, .color-orange i { color: @orangeIcon !important; } - -.btn-color-deep-orange{background-color: @deepOrangeIcon;} -.color-deep-orange, .color-deep-orange i { color: @deepOrangeIcon !important; } - -.btn-color-red{background-color: @redIcon;} -.color-red, .color-red i { color: @redIcon !important; } - -.btn-color-pink{background-color: @pinkIcon;} -.color-pink, .color-pink i { color: @pinkIcon !important; } - -.btn-color-purple{background-color: @purpleIcon;} -.color-purple, .color-purple i { color: @purpleIcon !important; } - -.btn-color-deep-purple{background-color: @deepPurpleIcon;} -.color-deep-purple, .color-deep-purple i { color: @deepPurpleIcon !important; } - -.btn-color-indigo{background-color: @indigoIcon;} -.color-indigo, .color-indigo i { color: @indigoIcon !important; } - - - // Scaffolding // ------------------------- @appHeaderHeight: 55px; @bodyBackground: @gray-10; -@textColor: #000; +@textColor: @black; @editorHeaderHeight: 70px; @editorFooterHeight: 50px; @@ -339,7 +261,7 @@ // Buttons // ------------------------- @btnBackground: @gray-9; -@btnBackgroundHighlight: @gray-9; +@btnBackgroundHighlight: @gray-10; @btnBorder: @gray-9; @btnPrimaryBackground: @ui-btn-positive; @@ -377,7 +299,7 @@ @inputBackground: @white; @inputBorder: @gray-8; @inputBorderFocus: @gray-7; -@inputBorderTabFocus: @blueExtraDark; +@inputBorderTabFocus: @ui-outline; @inputBorderRadius: 0; @inputDisabledBackground: @gray-10; @formActionsBackground: @gray-9; @@ -517,7 +439,7 @@ @heroUnitLeadColor: inherit; -// alerts +// Alerts // ------------------------- @warningText: @white; @warningBackground: @yellow-d2; @@ -532,7 +454,7 @@ @successBorder: transparent; @infoText: @white; -@infoBackground: @turquoise-d1; +@infoBackground: @blueDark; @infoBorder: transparent; @alertBorderRadius: 0; diff --git a/src/Umbraco.Web.UI.Client/src/controllers/main.controller.js b/src/Umbraco.Web.UI.Client/src/main.controller.js similarity index 92% rename from src/Umbraco.Web.UI.Client/src/controllers/main.controller.js rename to src/Umbraco.Web.UI.Client/src/main.controller.js index 654bbb1d03..883907d1dc 100644 --- a/src/Umbraco.Web.UI.Client/src/controllers/main.controller.js +++ b/src/Umbraco.Web.UI.Client/src/main.controller.js @@ -10,7 +10,7 @@ */ function MainController($scope, $location, appState, treeService, notificationsService, userService, historyService, updateChecker, navigationService, eventsService, - tmhDynamicLocale, localStorageService, editorService, overlayService) { + tmhDynamicLocale, localStorageService, editorService, overlayService, assetsService, tinyMceAssets) { //the null is important because we do an explicit bool check on this in the view $scope.authenticated = null; @@ -21,7 +21,13 @@ function MainController($scope, $location, appState, treeService, notificationsS $scope.search = {}; $scope.login = {}; $scope.tabbingActive = false; - + + // Load TinyMCE assets ahead of time in the background for the user + // To help with first load of the RTE + tinyMceAssets.forEach(function (tinyJsAsset) { + assetsService.loadJs(tinyJsAsset, $scope); + }); + // There are a number of ways to detect when a focus state should be shown when using the tab key and this seems to be the simplest solution. // For more information about this approach, see https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2 function handleFirstTab(evt) { @@ -61,13 +67,18 @@ function MainController($scope, $location, appState, treeService, notificationsS }; var evts = []; - + //when a user logs out or timesout evts.push(eventsService.on("app.notAuthenticated", function (evt, data) { $scope.authenticated = null; $scope.user = null; const isTimedOut = data && data.isTimedOut ? true : false; $scope.showLoginScreen(isTimedOut); + + // Remove the localstorage items for tours shown + // Means that when next logged in they can be re-shown if not already dismissed etc + localStorageService.remove("emailMarketingTourShown"); + localStorageService.remove("introTourShown"); })); evts.push(eventsService.on("app.userRefresh", function(evt) { diff --git a/src/Umbraco.Web.UI.Client/src/controllers/navigation.controller.js b/src/Umbraco.Web.UI.Client/src/navigation.controller.js similarity index 95% rename from src/Umbraco.Web.UI.Client/src/controllers/navigation.controller.js rename to src/Umbraco.Web.UI.Client/src/navigation.controller.js index e4c94f3c66..194c45afe6 100644 --- a/src/Umbraco.Web.UI.Client/src/controllers/navigation.controller.js +++ b/src/Umbraco.Web.UI.Client/src/navigation.controller.js @@ -212,6 +212,22 @@ function NavigationController($scope, $rootScope, $location, $log, $q, $routePar evts.push(eventsService.on("editors.languages.languageDeleted", function (e, args) { loadLanguages().then(function (languages) { $scope.languages = languages; + const defaultCulture = $scope.languages[0].culture; + + if (args.language.culture === $scope.selectedLanguage.culture) { + $scope.selectedLanguage = defaultCulture; + + if ($scope.languages.length > 1) { + $location.search("mculture", defaultCulture); + } else { + $location.search("mculture", null); + } + + var currentEditorState = editorState.getCurrent(); + if (currentEditorState && currentEditorState.path) { + $scope.treeApi.syncTree({ path: currentEditorState.path, activate: true }); + } + } }); })); @@ -255,7 +271,7 @@ function NavigationController($scope, $rootScope, $location, $log, $q, $routePar evts.push(eventsService.on("treeService.removeNode", function (e, args) { //check to see if the current page has been removed - var currentEditorState = editorState.getCurrent() + var currentEditorState = editorState.getCurrent(); if (currentEditorState && currentEditorState.id.toString() === args.node.id.toString()) { //current page is loaded, so navigate to root var section = appState.getSectionState("currentSection"); @@ -279,6 +295,9 @@ function NavigationController($scope, $rootScope, $location, $log, $q, $routePar //select the current language if set in the query string if (mainCulture && $scope.languages && $scope.languages.length > 1) { var found = _.find($scope.languages, function (l) { + if (mainCulture === true) { + return false; + } return l.culture.toLowerCase() === mainCulture.toLowerCase(); }); if (found) { @@ -348,7 +367,7 @@ function NavigationController($scope, $rootScope, $location, $log, $q, $routePar return contentResource.allowsCultureVariation().then(function (b) { if (b === true) { - return languageResource.getAll() + return languageResource.getAll(); } else { return $q.when([]); //resolve an empty collection } @@ -491,6 +510,14 @@ function NavigationController($scope, $rootScope, $location, $log, $q, $routePar if (!event) { return; } + closeTree(); + }; + + $scope.onOutsideClick = function() { + closeTree(); + }; + + function closeTree() { if (!appState.getGlobalState("touchDevice")) { treeActive = false; $timeout(function () { @@ -499,7 +526,7 @@ function NavigationController($scope, $rootScope, $location, $log, $q, $routePar } }, 300); } - }; + } $scope.toggleLanguageSelector = function () { $scope.page.languageSelectorIsOpen = !$scope.page.languageSelectorIsOpen; diff --git a/src/Umbraco.Web.UI.Client/src/preview/preview.controller.js b/src/Umbraco.Web.UI.Client/src/preview/preview.controller.js index 1316642e93..dc40338d01 100644 --- a/src/Umbraco.Web.UI.Client/src/preview/preview.controller.js +++ b/src/Umbraco.Web.UI.Client/src/preview/preview.controller.js @@ -7,6 +7,31 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi .controller("previewController", function ($scope, $window, $location) { + $scope.currentCulture = {iso:'', title:'...', icon:'icon-loading'} + var cultures = []; + + $scope.tabbingActive = false; + // There are a number of ways to detect when a focus state should be shown when using the tab key and this seems to be the simplest solution. + // For more information about this approach, see https://hackernoon.com/removing-that-ugly-focus-ring-and-keeping-it-too-6c8727fefcd2 + function handleFirstTab(evt) { + if (evt.keyCode === 9) { + $scope.tabbingActive = true; + $scope.$digest(); + window.removeEventListener('keydown', handleFirstTab); + window.addEventListener('mousedown', disableTabbingActive); + } + } + + function disableTabbingActive(evt) { + $scope.tabbingActive = false; + $scope.$digest(); + window.removeEventListener('mousedown', disableTabbingActive); + window.addEventListener("keydown", handleFirstTab); + } + + window.addEventListener("keydown", handleFirstTab); + + //gets a real query string value function getParameterByName(name, url) { if (!url) url = $window.location.href; @@ -75,15 +100,55 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi $scope.valueAreLoaded = false; $scope.devices = [ - { name: "desktop", css: "desktop", icon: "icon-display", title: "Desktop" }, - { name: "laptop - 1366px", css: "laptop border", icon: "icon-laptop", title: "Laptop" }, - { name: "iPad portrait - 768px", css: "iPad-portrait border", icon: "icon-ipad", title: "Tablet portrait" }, - { name: "iPad landscape - 1024px", css: "iPad-landscape border", icon: "icon-ipad flip", title: "Tablet landscape" }, - { name: "smartphone portrait - 480px", css: "smartphone-portrait border", icon: "icon-iphone", title: "Smartphone portrait" }, - { name: "smartphone landscape - 320px", css: "smartphone-landscape border", icon: "icon-iphone flip", title: "Smartphone landscape" } + { name: "fullsize", css: "fullsize", icon: "icon-application-window-alt", title: "Browser" }, + { name: "desktop", css: "desktop shadow", icon: "icon-display", title: "Desktop" }, + { name: "laptop - 1366px", css: "laptop shadow", icon: "icon-laptop", title: "Laptop" }, + { name: "iPad portrait - 768px", css: "iPad-portrait shadow", icon: "icon-ipad", title: "Tablet portrait" }, + { name: "iPad landscape - 1024px", css: "iPad-landscape shadow", icon: "icon-ipad flip", title: "Tablet landscape" }, + { name: "smartphone portrait - 480px", css: "smartphone-portrait shadow", icon: "icon-iphone", title: "Smartphone portrait" }, + { name: "smartphone landscape - 320px", css: "smartphone-landscape shadow", icon: "icon-iphone flip", title: "Smartphone landscape" } ]; $scope.previewDevice = $scope.devices[0]; + $scope.sizeOpen = false; + $scope.cultureOpen = false; + + $scope.toggleSizeOpen = function() { + $scope.sizeOpen = toggleMenu($scope.sizeOpen); + } + $scope.toggleCultureOpen = function() { + $scope.cultureOpen = toggleMenu($scope.cultureOpen); + } + + function toggleMenu(isCurrentlyOpen) { + if (isCurrentlyOpen === false) { + closeOthers(); + return true; + } else { + return false; + } + } + function closeOthers() { + $scope.sizeOpen = false; + $scope.cultureOpen = false; + } + + $scope.windowClickHandler = function() { + closeOthers(); + } + function windowBlurHandler() { + closeOthers(); + $scope.$digest(); + } + + var win = angular.element($window); + + win.on("blur", windowBlurHandler); + + $scope.$on("$destroy", function () { + win.off("blur", handleBlwindowBlurHandlerur ); + }); + function setPageUrl(){ $scope.pageId = $location.search().id || getParameterByName("id"); @@ -123,6 +188,8 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi $scope.onFrameLoaded = function (iframe) { $scope.frameLoaded = true; configureSignalR(iframe); + + $scope.currentCultureIso = $location.search().culture || null; }; /*****************************************************************************/ @@ -136,14 +203,32 @@ var app = angular.module("umbraco.preview", ['umbraco.resources', 'umbraco.servi /*****************************************************************************/ /* Change culture */ /*****************************************************************************/ - $scope.changeCulture = function (culture) { - if($location.search().culture !== culture){ + $scope.changeCulture = function (iso) { + if($location.search().culture !== iso) { $scope.frameLoaded = false; - $location.search("culture", culture); + $scope.currentCultureIso = iso; + $location.search("culture", iso); setPageUrl(); } }; - + $scope.registerCulture = function(iso, title, isDefault) { + var cultureObject = {iso: iso, title: title, isDefault: isDefault}; + cultures.push(cultureObject); + } + + $scope.$watch("currentCultureIso", function(oldIso, newIso) { + // if no culture is selected, we will pick the default one: + if ($scope.currentCultureIso === null) { + $scope.currentCulture = cultures.find(function(culture) { + return culture.isDefault === true; + }) + return; + } + $scope.currentCulture = cultures.find(function(culture) { + return culture.iso === $scope.currentCultureIso; + }) + }); + }) diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js index 3323f2bfb3..268bfb3a8c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function HelpDrawerController($scope, $routeParams, $timeout, dashboardResource, localizationService, userService, eventsService, helpService, appState, tourService, $filter) { + function HelpDrawerController($scope, $routeParams, $timeout, dashboardResource, localizationService, userService, eventsService, helpService, appState, tourService, $filter, editorState) { var vm = this; var evts = []; @@ -18,6 +18,10 @@ vm.startTour = startTour; vm.getTourGroupCompletedPercentage = getTourGroupCompletedPercentage; vm.showTourButton = showTourButton; + + vm.showDocTypeTour = false; + vm.docTypeTours = []; + vm.nodeName = ''; function startTour(tour) { tourService.startTour(tour); @@ -58,9 +62,16 @@ handleSectionChange(); })); + evts.push(eventsService.on("editorState.changed", + function (e, args) { + setDocTypeTour(args.entity); + })); + findHelp(vm.section, vm.tree, vm.userType, vm.userLang); }); + + setDocTypeTour(editorState.getCurrent()); // check if a tour is running - if it is open the matching group var currentTour = tourService.getCurrentTour(); @@ -84,7 +95,7 @@ setSectionName(); findHelp(vm.section, vm.tree, vm.userType, vm.userLang); - + setDocTypeTour(); } }); } @@ -168,6 +179,26 @@ }); } + function setDocTypeTour(node) { + vm.showDocTypeTour = false; + vm.docTypeTours = []; + vm.nodeName = ''; + + if (vm.section === 'content' && vm.tree === 'content') { + + if (node) { + tourService.getToursForDoctype(node.contentTypeAlias).then(function (data) { + if (data && data.length > 0) { + vm.docTypeTours = data; + var currentVariant = _.find(node.variants, (x) => x.active); + vm.nodeName = currentVariant.name; + vm.showDocTypeTour = true; + } + }); + } + } + } + evts.push(eventsService.on("appState.tour.complete", function (event, tour) { tourService.getGroupedTours().then(function(groupedTours) { vm.tours = groupedTours; diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html index 56db1fd88a..aa6126e73e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.html @@ -8,26 +8,56 @@ - -
+ +
+
Need help editing current item '{{vm.nodeName}}' ?
-
Tours
+
-
+ +
+
+
+
+ {{ tour.name }} +
+
+ +
+
+
+
+
+
+ + +
+ +
+ Tours +
+ +
- -
- +
+ + Other + + -
+
@@ -38,8 +68,8 @@ {{ tour.name }}
- - + +
@@ -47,33 +77,35 @@
-
+
- -
-
-
{{dashboard.label}}
-
-
-
+ +
+
+
{{dashboard.label}}
+
+
+
+
-
- -
-
Articles
-
-
+
    -
  • +
  • {{group.containerPath}}
  • @@ -77,7 +77,8 @@ checklist-model="model.compositeContentTypes" checklist-value="compositeContentType.contentType.alias" ng-change="model.selectCompositeContentType(compositeContentType.contentType)" - ng-disabled="compositeContentType.allowed===false || compositeContentType.inherited" /> + ng-disabled="compositeContentType.allowed===false || compositeContentType.inherited" /> +
+ +

+ Choose where to copy the selected item(s) +

+
-
- -
+ + + + + + + + + + @@ -83,7 +91,8 @@ button-style="success" label-key="general_submit" state="vm.saveButtonState" - action="vm.submit(model)"> + action="vm.submit(model)" + disabled="!model.target"> diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/datatypepicker/datatypepicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/datatypepicker/datatypepicker.controller.js index c86f55b255..167e74c25d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/datatypepicker/datatypepicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/datatypepicker/datatypepicker.controller.js @@ -10,7 +10,7 @@ (function() { "use strict"; - function DataTypePicker($scope, $filter, dataTypeResource, dataTypeHelper, contentTypeResource, localizationService, editorService) { + function DataTypePicker($scope, $filter, dataTypeResource, contentTypeResource, localizationService, editorService) { var vm = this; @@ -122,10 +122,18 @@ vm.showTabs = false; var regex = new RegExp(vm.searchTerm, "i"); + + var userConfigured = filterCollection(vm.userConfigured, regex), + typesAndEditors = filterCollection(vm.typesAndEditors, regex); + + var totalResults = _.reduce(_.pluck(_.union(userConfigured, typesAndEditors), 'count'), (m, n) => m + n, 0); + vm.filterResult = { - userConfigured: filterCollection(vm.userConfigured, regex), - typesAndEditors: filterCollection(vm.typesAndEditors, regex) + userConfigured: userConfigured, + typesAndEditors: typesAndEditors, + totalResults: totalResults }; + } else { vm.filterResult = null; vm.showTabs = true; @@ -134,11 +142,15 @@ function filterCollection(collection, regex) { return _.map(_.keys(collection), function (key) { + + var filteredDataTypes = $filter('filter')(collection[key], function (dataType) { + return regex.test(dataType.name) || regex.test(dataType.alias); + }); + return { group: key, - dataTypes: $filter('filter')(collection[key], function (dataType) { - return regex.test(dataType.name) || regex.test(dataType.alias); - }) + count: filteredDataTypes.length, + dataTypes: filteredDataTypes } }); } @@ -150,7 +162,6 @@ propertyDetails.title = property.name; $scope.model.itemDetails = propertyDetails; - } function hideDetailsOverlay() { @@ -177,7 +188,6 @@ }; editorService.open(dataTypeSettings); - } function pickDataType(selectedDataType) { @@ -205,7 +215,7 @@ } function close() { - if($scope.model.close) { + if ($scope.model.close) { $scope.model.close(); } } diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/datatypepicker/datatypepicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/datatypepicker/datatypepicker.html index fd859b9e2e..534fdc5648 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/datatypepicker/datatypepicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/datatypepicker/datatypepicker.html @@ -20,17 +20,19 @@
+ +
@@ -64,63 +67,75 @@
  • + ng-click="vm.pickDataType(dataType)" + class="cursor-pointer">
    - + {{ dataType.name }} - +
  • + -
    -
    + + + + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/embed/embed.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/embed/embed.controller.js index 06a5f028ef..515f54e3d7 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/embed/embed.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/embed/embed.controller.js @@ -7,6 +7,7 @@ var origWidth = 500; var origHeight = 300; + vm.loading = false; vm.trustedPreview = null; $scope.model.embed = { @@ -17,9 +18,16 @@ preview: "", success: false, info: "", - supportsDimensions: "" + supportsDimensions: false }; + if ($scope.model.modify) { + angular.extend($scope.model.embed, $scope.model.modify); + + showPreview(); + } + + vm.toggleConstrain = toggleConstrain; vm.showPreview = showPreview; vm.changeSize = changeSize; vm.submit = submit; @@ -37,10 +45,10 @@ if ($scope.model.embed.url) { $scope.model.embed.show = true; - $scope.model.embed.preview = "
    "; $scope.model.embed.info = ""; $scope.model.embed.success = false; + vm.loading = true; $http({ method: 'GET', @@ -54,29 +62,41 @@ $scope.model.embed.preview = ""; - switch (response.data.OEmbedStatus) { case 0: //not supported + $scope.model.embed.preview = ""; $scope.model.embed.info = "Not supported"; + $scope.model.embed.success = false; + $scope.model.embed.supportsDimensions = false; + vm.trustedPreview = null; break; case 1: //error + $scope.model.embed.preview = ""; $scope.model.embed.info = "Could not embed media - please ensure the URL is valid"; + $scope.model.embed.success = false; + $scope.model.embed.supportsDimensions = false; + vm.trustedPreview = null; break; case 2: + $scope.model.embed.success = true; + $scope.model.embed.supportsDimensions = response.data.SupportsDimensions; $scope.model.embed.preview = response.data.Markup; vm.trustedPreview = $sce.trustAsHtml(response.data.Markup); - $scope.model.embed.supportsDimensions = response.data.SupportsDimensions; - $scope.model.embed.success = true; break; } + + vm.loading = false; + }, function() { + $scope.model.embed.success = false; $scope.model.embed.supportsDimensions = false; $scope.model.embed.preview = ""; $scope.model.embed.info = "Could not embed media - please ensure the URL is valid"; - }); + vm.loading = false; + }); } else { $scope.model.embed.supportsDimensions = false; $scope.model.embed.preview = ""; @@ -105,6 +125,10 @@ } + function toggleConstrain() { + $scope.model.embed.constrain = !$scope.model.embed.constrain; + } + function submit() { if($scope.model && $scope.model.submit) { $scope.model.submit($scope.model); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/embed/embed.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/embed/embed.html index e48ec84b25..5862ca7059 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/embed/embed.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/embed/embed.html @@ -16,7 +16,7 @@ - + - - -

    -
    -
    - -
    - - + + + +
    + + +

    +
    + +
    + + + - - - + + + - - - + + + +
    +
    @@ -62,6 +68,7 @@ button-style="success" label-key="general_submit" state="vm.saveButtonState" + disabled="!model.embed.url.length || !model.embed.preview.length" action="vm.submit(model)"> diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/iconpicker/iconpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/iconpicker/iconpicker.controller.js index 471d23ae84..5bfee22c4e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/iconpicker/iconpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/iconpicker/iconpicker.controller.js @@ -16,7 +16,7 @@ function IconPickerController($scope, iconHelper, localizationService) { vm.close = close; vm.colors = [ - { name: "Black", value: "color-black" }, + { name: "Black", value: "color-black", default: true }, { name: "Blue Grey", value: "color-blue-grey" }, { name: "Grey", value: "color-grey" }, { name: "Brown", value: "color-brown" }, @@ -49,7 +49,7 @@ function IconPickerController($scope, iconHelper, localizationService) { }); // set a default color if nothing is passed in - vm.color = $scope.model.color ? findColor($scope.model.color) : vm.colors[0]; + vm.color = $scope.model.color ? findColor($scope.model.color) : vm.colors.find(x => x.default); // if an icon is passed in - preselect it vm.icon = $scope.model.icon ? $scope.model.icon : undefined; @@ -71,12 +71,13 @@ function IconPickerController($scope, iconHelper, localizationService) { } function findColor(value) { - return _.findWhere(vm.colors, {value: value}); + return vm.colors.find(x => x.value === value); } - function selectColor(color, $index, $event) { - $scope.model.color = color.value; - vm.color = color; + function selectColor(color) { + let newColor = (color || vm.colors.find(x => x.default)); + $scope.model.color = newColor.value; + vm.color = newColor; } function close() { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/iconpicker/iconpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/iconpicker/iconpicker.html index 3caa6ae03d..c2b0d0e458 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/iconpicker/iconpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/iconpicker/iconpicker.html @@ -46,9 +46,10 @@
    • - - - +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/insertfield/insertfield.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/insertfield/insertfield.controller.js index f2cc0dbecb..3de26ba99c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/insertfield/insertfield.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/insertfield/insertfield.controller.js @@ -5,8 +5,8 @@ var vm = this; - vm.field; - vm.defaultValue; + vm.field = null; + vm.defaultValue = null; vm.recursive = false; vm.showDefaultValue = false; @@ -16,10 +16,14 @@ function onInit() { + var labelKeys = [ + "template_insertPageField" + ]; + // set default title if(!$scope.model.title) { - localizationService.localize("template_insertPageField").then(function(value){ - $scope.model.title = value; + localizationService.localizeMany(labelKeys).then(function (data) { + $scope.model.title = data[0]; }); } @@ -37,42 +41,40 @@ function generateOutputSample() { - var fallback; + var fallback = null; - if(vm.recursive !== false && vm.defaultValue !== undefined){ + if (vm.recursive !== false && vm.defaultValue !== null) { fallback = "Fallback.To(Fallback.Ancestors, Fallback.DefaultValue)"; - }else if(vm.recursive !== false){ + } else if (vm.recursive !== false) { fallback = "Fallback.ToAncestors"; - }else if(vm.defaultValue !== undefined){ + } else if (vm.defaultValue !== null) { fallback = "Fallback.ToDefaultValue"; } - var pageField = (vm.field !== undefined ? '@Model.Value("' + vm.field + '"' : "") - + (fallback !== undefined? ', fallback: ' + fallback : "") - + (vm.defaultValue !== undefined ? ', defaultValue: new HtmlString("' + vm.defaultValue + '")' : "") + var pageField = (vm.field !== null ? '@Model.Value("' + vm.field + '"' : "") + + (fallback !== null? ', fallback: ' + fallback : "") + + (vm.defaultValue !== null ? ', defaultValue: new HtmlString("' + vm.defaultValue + '")' : "") + (vm.field ? ')' : ""); $scope.model.umbracoField = pageField; return pageField; - } function submit(model) { - if($scope.model.submit) { + if ($scope.model.submit) { $scope.model.submit(model); } } function close() { - if($scope.model.close) { + if ($scope.model.close) { $scope.model.close(); } } onInit(); - } angular.module("umbraco").controller("Umbraco.Editors.InsertFieldController", InsertFieldController); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/insertfield/insertfield.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/insertfield/insertfield.html index b2c6382b98..bbb2e8c798 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/insertfield/insertfield.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/insertfield/insertfield.html @@ -33,10 +33,9 @@
    - +
    @@ -52,13 +51,17 @@
    -
    diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js index 5da14fc6d3..47607b7f0b 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js @@ -33,6 +33,7 @@ angular.module("umbraco").controller("Umbraco.Editors.LinkPickerController", }; $scope.showTarget = $scope.model.hideTarget !== true; + $scope.showAnchor = $scope.model.hideAnchor !== true; // this ensures that we only sync the tree once and only when it's ready var oneTimeTreeSync = { @@ -93,7 +94,7 @@ angular.module("umbraco").controller("Umbraco.Editors.LinkPickerController", }); } - } else if ($scope.model.target.url.length) { + } else if ($scope.model.target.url && $scope.model.target.url.length) { // a url but no id/udi indicates an external link - trim the url to remove the anchor/qs // only do the substring if there's a # or a ? var indexOfAnchor = $scope.model.target.url.search(/(#|\?)/); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.html index 704b61e333..ad0aaab57c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.html @@ -1,4 +1,4 @@ -
    +
    @@ -14,19 +14,20 @@ -
    +
    - + - + m + n, 0); + + vm.filterResult = { + parameterEditors: parameterEditors, + totalResults: totalResults + }; + } else { + vm.filterResult = null; + } + } + + function filterCollection(collection, regex) { + return _.map(_.keys(collection), function (key) { + + var filteredEditors = $filter('filter')(collection[key], function (editor) { + return regex.test(editor.name) || regex.test(editor.alias); + }); + + return { + group: key, + count: filteredEditors.length, + parameterEditors: filteredEditors + } + }); + } + + function showDetailsOverlay(property) { + + var propertyDetails = {}; + propertyDetails.icon = property.icon; + propertyDetails.title = property.name; + + $scope.model.itemDetails = propertyDetails; + } + + function hideDetailsOverlay() { + $scope.model.itemDetails = null; + } + + function pickParameterEditor(selectedParameterEditor) { + + console.log("pickParameterEditor", selectedParameterEditor); + console.log("$scope.model", $scope.model); + + $scope.model.parameter.editor = selectedParameterEditor.alias; + $scope.model.parameter.dataTypeName = selectedParameterEditor.name; + $scope.model.parameter.dataTypeIcon = selectedParameterEditor.icon; + + $scope.model.submit($scope.model); + } + + function close() { + if ($scope.model.close) { + $scope.model.close(); + } + } + + init(); + } + + angular.module("umbraco").controller("Umbraco.Editors.MacroParameterPickerController", MacroParameterController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macroparameterpicker/macroparameterpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macroparameterpicker/macroparameterpicker.html new file mode 100644 index 0000000000..9f2b56401d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macroparameterpicker/macroparameterpicker.html @@ -0,0 +1,106 @@ +
    + + + + + + + + + + + + + +
    + +
    + + + +
    + +
    + + +
    +
    +
    +
    +
    {{result.group}}
    + +
    +
    +
    + + + + +
    + +
    +
    +
    + + + + + + + + + + +
    + +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macropicker/macropicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macropicker/macropicker.controller.js index aa63f2d6d6..dfc77f786c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macropicker/macropicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macropicker/macropicker.controller.js @@ -41,8 +41,6 @@ function MacroPickerController($scope, entityResource, macroResource, umbPropEdi macroResource.getMacroParameters($scope.model.selectedMacro.id) .then(function (data) { - - //go to next page if there are params otherwise we can just exit if (!angular.isArray(data) || data.length === 0) { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macropicker/macropicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macropicker/macropicker.html index 66c64657a9..fc1bec4ec1 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macropicker/macropicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macropicker/macropicker.html @@ -32,10 +32,10 @@
    • - + - {{ availableItem.name }} + {{availableItem.name}}
    • diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediapicker/mediapicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediapicker/mediapicker.controller.js index 5efc25fa10..4419805194 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediapicker/mediapicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediapicker/mediapicker.controller.js @@ -1,61 +1,76 @@ //used for the media picker dialog angular.module("umbraco") .controller("Umbraco.Editors.MediaPickerController", - function ($scope, mediaResource, entityResource, userService, mediaHelper, mediaTypeHelper, eventsService, treeService, localStorageService, localizationService, editorService) { + function ($scope, $timeout, mediaResource, entityResource, userService, mediaHelper, mediaTypeHelper, eventsService, treeService, localStorageService, localizationService) { - if (!$scope.model.title) { - localizationService.localizeMany(["defaultdialogs_selectMedia", "general_includeFromsubFolders"]) - .then(function (data) { - $scope.labels = { - title: data[0], - includeSubFolders: data[1] - } - }); - } + var vm = this; + + vm.submit = submit; + vm.close = close; + + vm.toggle = toggle; + vm.upload = upload; + vm.dragLeave = dragLeave; + vm.dragEnter = dragEnter; + vm.onUploadComplete = onUploadComplete; + vm.onFilesQueue = onFilesQueue; + vm.changeSearch = changeSearch; + vm.submitFolder = submitFolder; + vm.enterSubmitFolder = enterSubmitFolder; + vm.focalPointChanged = focalPointChanged; + vm.changePagination = changePagination; + + vm.clickHandler = clickHandler; + vm.clickItemName = clickItemName; + vm.gotoFolder = gotoFolder; + vm.shouldShowUrl = shouldShowUrl; var dialogOptions = $scope.model; - - $scope.disableFolderSelect = dialogOptions.disableFolderSelect; - $scope.onlyImages = dialogOptions.onlyImages; - $scope.showDetails = dialogOptions.showDetails; + + $scope.disableFolderSelect = (dialogOptions.disableFolderSelect && dialogOptions.disableFolderSelect !== "0") ? true : false; + $scope.disableFocalPoint = (dialogOptions.disableFocalPoint && dialogOptions.disableFocalPoint !== "0") ? true : false; + $scope.onlyImages = (dialogOptions.onlyImages && dialogOptions.onlyImages !== "0") ? true : false; + $scope.onlyFolders = (dialogOptions.onlyFolders && dialogOptions.onlyFolders !== "0") ? true : false; + $scope.showDetails = (dialogOptions.showDetails && dialogOptions.showDetails !== "0") ? true : false; $scope.multiPicker = (dialogOptions.multiPicker && dialogOptions.multiPicker !== "0") ? true : false; $scope.startNodeId = dialogOptions.startNodeId ? dialogOptions.startNodeId : -1; $scope.cropSize = dialogOptions.cropSize; $scope.lastOpenedNode = localStorageService.get("umbLastOpenedMediaNodeId"); $scope.lockedFolder = true; - $scope.allowMediaEdit = dialogOptions.allowMediaEdit ? dialogOptions.allowMediaEdit : false; var userStartNodes = []; var umbracoSettings = Umbraco.Sys.ServerVariables.umbracoSettings; var allowedUploadFiles = mediaHelper.formatFileTypes(umbracoSettings.allowedUploadFiles); + if ($scope.onlyImages) { - $scope.acceptedFileTypes = mediaHelper.formatFileTypes(umbracoSettings.imageFileTypes); + vm.acceptedFileTypes = mediaHelper.formatFileTypes(umbracoSettings.imageFileTypes); } else { // Use whitelist of allowed file types if provided if (allowedUploadFiles !== '') { - $scope.acceptedFileTypes = allowedUploadFiles; + vm.acceptedFileTypes = allowedUploadFiles; } else { // If no whitelist, we pass in a blacklist by adding ! to the file extensions, allowing everything EXCEPT for disallowedUploadFiles - $scope.acceptedFileTypes = !mediaHelper.formatFileTypes(umbracoSettings.disallowedUploadFiles); + vm.acceptedFileTypes = !mediaHelper.formatFileTypes(umbracoSettings.disallowedUploadFiles); } } - $scope.maxFileSize = umbracoSettings.maxFileSize + "KB"; + vm.maxFileSize = umbracoSettings.maxFileSize + "KB"; $scope.model.selection = []; - $scope.acceptedMediatypes = []; + vm.acceptedMediatypes = []; mediaTypeHelper.getAllowedImagetypes($scope.startNodeId) .then(function (types) { - $scope.acceptedMediatypes = types; + vm.acceptedMediatypes = types; }); var dataTypeKey = null; - if($scope.model && $scope.model.dataTypeKey) { + if ($scope.model && $scope.model.dataTypeKey) { dataTypeKey = $scope.model.dataTypeKey; } - $scope.searchOptions = { + + vm.searchOptions = { pageNumber: 1, pageSize: 100, totalItems: 0, @@ -64,13 +79,26 @@ angular.module("umbraco") dataTypeKey: dataTypeKey }; - //preload selected item - $scope.target = undefined; + // preload selected item + $scope.target = null; + if (dialogOptions.currentTarget) { $scope.target = dialogOptions.currentTarget; } + function setTitle() { + if (!$scope.model.title) { + localizationService.localize("defaultdialogs_selectMedia") + .then(function (data) { + $scope.model.title = data; + }); + } + } + function onInit() { + + setTitle(); + userService.getCurrentUser().then(function (userData) { userStartNodes = userData.startMediaIds; @@ -96,37 +124,46 @@ angular.module("umbraco") gotoStartNode(); } } else { - //if a target is specified, go look it up - generally this target will just contain ids not the actual full - //media object so we need to look it up + // if a target is specified, go look it up - generally this target will just contain ids not the actual full + // media object so we need to look it up var id = $scope.target.udi ? $scope.target.udi : $scope.target.id; var altText = $scope.target.altText; - entityResource.getById(id, "Media") + + // ID of a UDI or legacy int ID still could be null/undefinied here + // As user may dragged in an image that has not been saved to media section yet + if (id) { + entityResource.getById(id, "Media") .then(function (node) { $scope.target = node; if (ensureWithinStartNode(node)) { - selectImage(node); - $scope.target.url = mediaHelper.resolveFile(node); + selectMedia(node); + $scope.target.url = mediaHelper.resolveFileFromEntity(node); + $scope.target.thumbnail = mediaHelper.resolveFileFromEntity(node, true); $scope.target.altText = altText; - $scope.openDetailsDialog(); + openDetailsDialog(); } - }, - gotoStartNode); + }, gotoStartNode); + } else { + // No ID set - then this is going to be a tmpimg that has not been uploaded + // User editing this will want to be changing the ALT text + openDetailsDialog(); + } } } - $scope.upload = function (v) { + function upload(v) { angular.element(".umb-file-dropzone .file-select").trigger("click"); - }; + } - $scope.dragLeave = function (el, event) { + function dragLeave(el, event) { $scope.activeDrag = false; - }; + } - $scope.dragEnter = function (el, event) { + function dragEnter(el, event) { $scope.activeDrag = true; - }; + } - $scope.submitFolder = function () { + function submitFolder() { if ($scope.model.newFolderName) { $scope.model.creatingFolder = true; mediaResource @@ -138,25 +175,25 @@ angular.module("umbraco") childrenOf: data.parentId //clear the children of the parent }); $scope.model.creatingFolder = false; - $scope.gotoFolder(data); + gotoFolder(data); $scope.model.showFolderInput = false; $scope.model.newFolderName = ""; }); } else { $scope.model.showFolderInput = false; } - }; + } - $scope.enterSubmitFolder = function (event) { + function enterSubmitFolder(event) { if (event.keyCode === 13) { - $scope.submitFolder(); + submitFolder(); event.stopPropagation(); } - }; + } - $scope.gotoFolder = function (folder) { + function gotoFolder(folder) { if (!$scope.multiPicker) { - deselectAllImages($scope.model.selection); + deselectAllMedia($scope.model.selection); } if (!folder) { @@ -174,93 +211,107 @@ angular.module("umbraco") mediaTypeHelper.getAllowedImagetypes(folder.id) .then(function (types) { - $scope.acceptedMediatypes = types; + vm.acceptedMediatypes = types; }); } else { $scope.path = []; } $scope.lockedFolder = (folder.id === -1 && $scope.model.startNodeIsVirtual) || hasFolderAccess(folder) === false; - $scope.currentFolder = folder; - localStorageService.set("umbLastOpenedMediaNodeId", folder.id); - return getChildren(folder.id); - }; - $scope.clickHandler = function (image, event, index) { - if (image.isFolder) { + localStorageService.set("umbLastOpenedMediaNodeId", folder.id); + + return getChildren(folder.id); + } + + function clickHandler(media, event, index) { + + if (media.isFolder) { if ($scope.disableFolderSelect) { - $scope.gotoFolder(image); + gotoFolder(media); } else { - eventsService.emit("dialogs.mediaPicker.select", image); - selectImage(image); + selectMedia(media); } } else { - eventsService.emit("dialogs.mediaPicker.select", image); if ($scope.showDetails) { - - $scope.target = image; - + + $scope.target = media; + // handle both entity and full media object - if (image.image) { - $scope.target.url = image.image; + if (media.image) { + $scope.target.url = media.image; } else { - $scope.target.url = mediaHelper.resolveFile(image); + $scope.target.url = mediaHelper.resolveFile(media); } - - $scope.openDetailsDialog(); + + openDetailsDialog(); } else { - selectImage(image); + selectMedia(media); } } - }; + } - $scope.clickItemName = function (item) { + function clickItemName(item, event, index) { if (item.isFolder) { - $scope.gotoFolder(item); + gotoFolder(item); + } + else { + $scope.clickHandler(item, event, index); } }; - function selectImage(image) { - if (image.selected) { + function selectMedia(media) { + if (!media.selectable) { + return; + } + if (media.selected) { for (var i = 0; $scope.model.selection.length > i; i++) { var imageInSelection = $scope.model.selection[i]; - if (image.key === imageInSelection.key) { - image.selected = false; + if (media.key === imageInSelection.key) { + media.selected = false; $scope.model.selection.splice(i, 1); } } } else { if (!$scope.multiPicker) { - deselectAllImages($scope.model.selection); + deselectAllMedia($scope.model.selection); } - image.selected = true; - $scope.model.selection.push(image); + eventsService.emit("dialogs.mediaPicker.select", media); + media.selected = true; + $scope.model.selection.push(media); } } - function deselectAllImages(images) { - for (var i = 0; i < images.length; i++) { - var image = images[i]; - image.selected = false; + function deselectAllMedia(medias) { + for (var i = 0; i < medias.length; i++) { + var media = medias[i]; + media.selected = false; } - images.length = 0; + medias.length = 0; } - $scope.onUploadComplete = function (files) { - $scope.gotoFolder($scope.currentFolder).then(function () { - if (files.length === 1 && $scope.model.selection.length === 0) { - var image = $scope.images[$scope.images.length - 1]; - $scope.target = image; - $scope.target.url = mediaHelper.resolveFile(image); - selectImage(image); - } + function onUploadComplete(files) { + gotoFolder($scope.currentFolder).then(function () { + $timeout(function () { + if ($scope.multiPicker) { + var images = _.rest($scope.images, $scope.images.length - files.length); + _.each(images, function(image) { + selectMedia(image); + }); + } else { + var image = $scope.images[$scope.images.length - 1]; + $scope.target = image; + $scope.target.url = mediaHelper.resolveFile(image); + selectMedia(image); + } + }); }); - }; + } - $scope.onFilesQueue = function () { + function onFilesQueue() { $scope.activeDrag = false; - }; + } function ensureWithinStartNode(node) { // make sure that last opened node is on the same path as start node @@ -268,10 +319,10 @@ angular.module("umbraco") // also make sure the node is not trashed if (nodePath.indexOf($scope.startNodeId.toString()) !== -1 && node.trashed === false) { - $scope.gotoFolder({ id: $scope.lastOpenedNode, name: "Media", icon: "icon-folder" }); + gotoFolder({ id: $scope.lastOpenedNode, name: "Media", icon: "icon-folder", path: node.path }); return true; } else { - $scope.gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" }); + gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" }); return false; } } @@ -288,36 +339,40 @@ angular.module("umbraco") } function gotoStartNode(err) { - $scope.gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" }); + gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" }); } - $scope.openDetailsDialog = function () { + function openDetailsDialog() { + localizationService.localize("defaultdialogs_editSelectedMedia").then(function (data) { + vm.mediaPickerDetailsOverlay = { + show: true, + title: data, + disableFocalPoint: $scope.disableFocalPoint, + submit: function (model) { + $scope.model.selection.push($scope.target); + $scope.model.submit($scope.model); - $scope.mediaPickerDetailsOverlay = {}; - $scope.mediaPickerDetailsOverlay.show = true; + vm.mediaPickerDetailsOverlay.show = false; + vm.mediaPickerDetailsOverlay = null; + }, + close: function (oldModel) { + vm.mediaPickerDetailsOverlay.show = false; + vm.mediaPickerDetailsOverlay = null; - $scope.mediaPickerDetailsOverlay.submit = function (model) { - $scope.model.selection.push($scope.target); - $scope.model.submit($scope.model); - - $scope.mediaPickerDetailsOverlay.show = false; - $scope.mediaPickerDetailsOverlay = null; - }; - - $scope.mediaPickerDetailsOverlay.close = function (oldModel) { - $scope.mediaPickerDetailsOverlay.show = false; - $scope.mediaPickerDetailsOverlay = null; - }; + close(); + } + }; + }); }; var debounceSearchMedia = _.debounce(function () { $scope.$apply(function () { - if ($scope.searchOptions.filter) { + if (vm.searchOptions.filter) { searchMedia(); } else { // reset pagination - $scope.searchOptions = { + vm.searchOptions = { pageNumber: 1, pageSize: 100, totalItems: 0, @@ -325,47 +380,52 @@ angular.module("umbraco") filter: '', dataTypeKey: dataTypeKey }; + getChildren($scope.currentFolder.id); } }); }, 500); - $scope.changeSearch = function () { - $scope.loading = true; + function changeSearch() { + vm.loading = true; debounceSearchMedia(); - }; - - $scope.toggle = function () { - // Make sure to activate the changeSearch function everytime the toggle is clicked - $scope.changeSearch(); } - $scope.changePagination = function (pageNumber) { - $scope.loading = true; - $scope.searchOptions.pageNumber = pageNumber; + function toggle() { + // Make sure to activate the changeSearch function everytime the toggle is clicked + changeSearch(); + } + + function changePagination(pageNumber) { + vm.loading = true; + vm.searchOptions.pageNumber = pageNumber; searchMedia(); }; function searchMedia() { - $scope.loading = true; - entityResource.getPagedDescendants($scope.currentFolder.id, "Media", $scope.searchOptions) + vm.loading = true; + entityResource.getPagedDescendants($scope.currentFolder.id, "Media", vm.searchOptions) .then(function (data) { // update image data to work with image grid angular.forEach(data.items, function (mediaItem) { setMediaMetaData(mediaItem); }); + // update images $scope.images = data.items ? data.items : []; + // update pagination if (data.pageNumber > 0) - $scope.searchOptions.pageNumber = data.pageNumber; + vm.searchOptions.pageNumber = data.pageNumber; if (data.pageSize > 0) - $scope.searchOptions.pageSize = data.pageSize; - $scope.searchOptions.totalItems = data.totalItems; - $scope.searchOptions.totalPages = data.totalPages; - // set already selected images to selected - preSelectImages(); - $scope.loading = false; + vm.searchOptions.pageSize = data.pageSize; + + vm.searchOptions.totalItems = data.totalItems; + vm.searchOptions.totalPages = data.totalPages; + + // set already selected medias to selected + preSelectMedia(); + vm.loading = false; }); } @@ -373,6 +433,7 @@ angular.module("umbraco") // set thumbnail and src mediaItem.thumbnail = mediaHelper.resolveFileFromEntity(mediaItem, true); mediaItem.image = mediaHelper.resolveFileFromEntity(mediaItem, false); + // set properties to match a media object if (mediaItem.metaData) { mediaItem.properties = []; @@ -404,33 +465,39 @@ angular.module("umbraco") } function getChildren(id) { - $scope.loading = true; - return entityResource.getChildren(id, "Media", $scope.searchOptions) - .then(function (data) { - for (var i = 0; i < data.length; i++) { - if (data[i].metaData.MediaPath !== null) { - data[i].thumbnail = mediaHelper.resolveFileFromEntity(data[i], true); - data[i].image = mediaHelper.resolveFileFromEntity(data[i], false); - } + vm.loading = true; + return entityResource.getChildren(id, "Media", vm.searchOptions).then(function (data) { + + var allowedTypes = dialogOptions.filter ? dialogOptions.filter.split(",") : null; + + for (var i = 0; i < data.length; i++) { + if (data[i].metaData.MediaPath !== null) { + data[i].thumbnail = mediaHelper.resolveFileFromEntity(data[i], true); + data[i].image = mediaHelper.resolveFileFromEntity(data[i], false); } - $scope.searchOptions.filter = ""; - $scope.images = data ? data : []; - // set already selected images to selected - preSelectImages(); - $scope.loading = false; - }); + + data[i].filtered = allowedTypes && allowedTypes.indexOf(data[i].metaData.ContentTypeAlias) < 0; + } + + vm.searchOptions.filter = ""; + $scope.images = data ? data : []; + + // set already selected medias to selected + preSelectMedia(); + vm.loading = false; + }); } - function preSelectImages() { - for (var folderImageIndex = 0; folderImageIndex < $scope.images.length; folderImageIndex++) { - var folderImage = $scope.images[folderImageIndex]; + function preSelectMedia() { + for (var folderIndex = 0; folderIndex < $scope.images.length; folderIndex++) { + var folderImage = $scope.images[folderIndex]; var imageIsSelected = false; if ($scope.model && angular.isArray($scope.model.selection)) { - for (var selectedImageIndex = 0; - selectedImageIndex < $scope.model.selection.length; - selectedImageIndex++) { - var selectedImage = $scope.model.selection[selectedImageIndex]; + for (var selectedIndex = 0; + selectedIndex < $scope.model.selection.length; + selectedIndex++) { + var selectedImage = $scope.model.selection[selectedIndex]; if (folderImage.key === selectedImage.key) { imageIsSelected = true; @@ -444,30 +511,19 @@ angular.module("umbraco") } } - $scope.editMediaItem = function (item) { - var mediaEditor = { - id: item.id, - submit: function (model) { - editorService.close() - // update the media picker item in the picker so it matched the saved media item - // the media picker is using media entities so we get the - // entity so we easily can format it for use in the media grid - if (model && model.mediaNode) { - entityResource.getById(model.mediaNode.id, "media") - .then(function (mediaEntity) { - angular.extend(item, mediaEntity); - setMediaMetaData(item); - setUpdatedMediaNodes(item); - }); - } - }, - close: function (model) { - setUpdatedMediaNodes(item); - editorService.close(); - } + + /** + * Called when the umbImageGravity component updates the focal point value + * @param {any} left + * @param {any} top + */ + function focalPointChanged(left, top) { + // update the model focalpoint value + $scope.target.focalPoint = { + left: left, + top: top }; - editorService.mediaEditor(mediaEditor); - }; + } function setUpdatedMediaNodes(item) { // add udi to list of updated media items so we easily can update them in other editors @@ -476,17 +532,30 @@ angular.module("umbraco") } } - $scope.submit = function () { - if ($scope.model.submit) { + function shouldShowUrl() { + if (!$scope.target) { + return false; + } + if ($scope.target.id) { + return false; + } + if ($scope.target.url && $scope.target.url.toLower().indexOf("blob:") === 0) { + return false; + } + return true; + } + + function submit() { + if ($scope.model && $scope.model.submit) { $scope.model.submit($scope.model); } - }; + } - $scope.close = function () { - if ($scope.model.close) { + function close() { + if ($scope.model && $scope.model.close) { $scope.model.close($scope.model); } - }; + } onInit(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediapicker/mediapicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediapicker/mediapicker.html index 6eee269cee..82612d5e19 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediapicker/mediapicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediapicker/mediapicker.html @@ -1,192 +1,226 @@
      - + - - + + - + -
      + -
      +
      + +
      + +
      + + +
      + + +
      +
      + +
      + +
      +
        +
      • + + +
      • + +
      • + + +
      • + +
      • + + + +
      • +
      +
      +
      + + + + + + -
      + ng-if="vm.loading"> - -
      - -
      + + +
      +
      + +
      +
      - - - - - - -
      - - +
      +
      + +
      +
      - - - - -
      - - -
      -
      - - -
      - -
      - +
      Preview
      - - + {{target.name}} +
      + +
      +
      + Focal point +
      + +
      + + +
      + +
      + +
      + Preview +
      + + + + +
      +
      -
      +
      -
      - - -
      + -
      - - -
      + + + -
      + + - + + - + + - - + - - - - - - - - - - - -
      +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/move/move.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/move/move.html index 8424bcefbe..1c9fd5b822 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/move/move.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/move/move.html @@ -13,6 +13,9 @@ +

      + Choose where to move the selected item(s) +

      @@ -75,7 +78,8 @@ button-style="success" label-key="general_submit" state="vm.saveButtonState" - action="vm.submit(model)"> + action="vm.submit(model)" + disabled="!model.target"> diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/propertysettings/propertysettings.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/propertysettings/propertysettings.html index fab6ba4069..4474390199 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/propertysettings/propertysettings.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/propertysettings/propertysettings.html @@ -81,39 +81,55 @@
      - +
      - -
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/querybuilder/querybuilder.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/querybuilder/querybuilder.html index 8376f50713..37a0d41207 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/querybuilder/querybuilder.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/querybuilder/querybuilder.html @@ -15,164 +15,157 @@ -
      +
      -
      -
      +
      +
      - I want + I want - - from + from - - + + -
      +
      -
      +
      - - where - - - and - + + where + + + and + -
      + +
      -
      +
      - - + + - - - - {{term.name}} - - - + + + + {{term.name}} + + + -
      +
      - + - - - + + + - - - - + + + + - + - - - + + + - - - + + + -
      +
      -
      +
      - order by + order by -
      + +
      - - + + -
      -
      +
      +
      {{model.result.resultCount}} items, returned in {{model.result.executionTime}} ms
      - + -
      {{model.result.queryExpression}}
      + {{model.result.queryExpression}} -
      +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/rollback/rollback.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/rollback/rollback.controller.js index afec0ae120..f71eb2c51e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/rollback/rollback.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/rollback/rollback.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function RollbackController($scope, contentResource, localizationService, assetsService) { + function RollbackController($scope, contentResource, localizationService, assetsService, dateHelper, userService) { var vm = this; @@ -90,11 +90,15 @@ const culture = $scope.model.node.variants.length > 1 ? vm.currentVersion.language.culture : null; return contentResource.getRollbackVersions(nodeId, culture) - .then(function(data){ - vm.previousVersions = data.map(version => { - version.displayValue = version.versionDate + " - " + version.versionAuthorName; - return version; - }); + .then(function (data) { + // get current backoffice user and format dates + userService.getCurrentUser().then(function (currentUser) { + vm.previousVersions = data.map(version => { + var timestampFormatted = dateHelper.getLocalDate(version.versionDate, currentUser.locale, 'LLL'); + version.displayValue = timestampFormatted + ' - ' + version.versionAuthorName; + return version; + }); + }); }); } diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/sectionpicker/sectionpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/sectionpicker/sectionpicker.html index 2e88bf709c..33906dcd75 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/sectionpicker/sectionpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/sectionpicker/sectionpicker.html @@ -19,10 +19,10 @@
        -
      • -
      • + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/templatesections/templatesections.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/templatesections/templatesections.controller.js index 36d7c0f4ed..8c728150da 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/templatesections/templatesections.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/templatesections/templatesections.controller.js @@ -1,26 +1,42 @@ (function () { "use strict"; - function TemplateSectionsController($scope, formHelper) { + function TemplateSectionsController($scope, formHelper, localizationService) { var vm = this; + vm.labels = {}; + vm.select = select; vm.submit = submit; vm.close = close; $scope.model.mandatoryRenderSection = false; - if(!$scope.model.title) { - $scope.model.title = "Sections"; - } - function onInit() { - if($scope.model.hasMaster) { + if ($scope.model.hasMaster) { $scope.model.insertType = 'addSection'; } else { $scope.model.insertType = 'renderBody'; } + + var labelKeys = [ + "template_insertSections", + "template_sectionMandatory" + ]; + + localizationService.localizeMany(labelKeys).then(function (data) { + vm.labels.title = data[0]; + vm.labels.sectionMandatory = data[1]; + + setTitle(vm.labels.title); + }); + } + + function setTitle(value) { + if (!$scope.model.title) { + $scope.model.title = value; + } } function select(type) { @@ -34,13 +50,12 @@ } function close() { - if($scope.model.close) { + if ($scope.model.close) { $scope.model.close(); } } onInit(); - } angular.module("umbraco").controller("Umbraco.Editors.TemplateSectionsController", TemplateSectionsController); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/templatesections/templatesections.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/templatesections/templatesections.html index 5b946976d7..045a1403e2 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/templatesections/templatesections.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/templatesections/templatesections.html @@ -44,10 +44,10 @@
      - - +
      + +
      +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/treepicker/treepicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/treepicker/treepicker.controller.js index 31430c81cb..0faee7bf90 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/treepicker/treepicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/treepicker/treepicker.controller.js @@ -58,6 +58,7 @@ angular.module("umbraco").controller("Umbraco.Editors.TreePickerController", vm.hideSearch = hideSearch; vm.closeMiniListView = closeMiniListView; vm.selectListViewNode = selectListViewNode; + vm.listViewItemsLoaded = listViewItemsLoaded; vm.submit = submit; vm.close = close; @@ -96,7 +97,7 @@ angular.module("umbraco").controller("Umbraco.Editors.TreePickerController", }); } } - if (vm.treeAlias === "documentTypes") { + else if (vm.treeAlias === "documentTypes") { vm.entityType = "DocumentType"; if (!$scope.model.title) { localizationService.localize("defaultdialogs_selectContentType").then(function(value){ @@ -107,9 +108,17 @@ angular.module("umbraco").controller("Umbraco.Editors.TreePickerController", else if (vm.treeAlias === "member" || vm.section === "member") { vm.entityType = "Member"; if (!$scope.model.title) { - localizationService.localize("defaultdialogs_selectMember").then(function(value){ + localizationService.localize("defaultdialogs_selectMember").then(function(value) { $scope.model.title = value; - }) + }); + } + } + else if (vm.treeAlias === "memberTypes") { + vm.entityType = "MemberType"; + if (!$scope.model.title) { + localizationService.localize("defaultdialogs_selectMemberType").then(function(value){ + $scope.model.title = value; + }); } } else if (vm.treeAlias === "media" || vm.section === "media") { @@ -120,6 +129,14 @@ angular.module("umbraco").controller("Umbraco.Editors.TreePickerController", }); } } + else if (vm.treeAlias === "mediaTypes") { + vm.entityType = "MediaType"; + if (!$scope.model.title) { + localizationService.localize("defaultdialogs_selectMediaType").then(function(value){ + $scope.model.title = value; + }); + } + } // TODO: Seems odd this logic is here, i don't think it needs to be and should just exist on the property editor using this if ($scope.model.minNumber) { @@ -399,7 +416,7 @@ angular.module("umbraco").controller("Umbraco.Editors.TreePickerController", if ($scope.model.selection.length > 0) { for (var i = 0; $scope.model.selection.length > i; i++) { var selectedItem = $scope.model.selection[i]; - if (selectedItem.id === item.id) { + if (selectedItem.id === parseInt(item.id)) { found = true; foundIndex = i; } @@ -614,8 +631,8 @@ angular.module("umbraco").controller("Umbraco.Editors.TreePickerController", _.each(vm.searchInfo.results, function (result) { var exists = _.find($scope.model.selection, - function (selectedId) { - return result.id == selectedId; + function (item) { + return result.id == item.id; }); if (exists) { result.selected = true; @@ -635,6 +652,15 @@ angular.module("umbraco").controller("Umbraco.Editors.TreePickerController", vm.miniListView = undefined; } + function listViewItemsLoaded(items) { + var selectedIds = _.pluck($scope.model.selection, "id"); + _.each(items, function (item) { + if (_.contains(selectedIds, item.id)) { + item.selected = true; + } + }); + } + function submit(model) { if($scope.model.submit) { $scope.model.submit(model); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/treepicker/treepicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/treepicker/treepicker.html index 78c75f6f8d..ef042205aa 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/treepicker/treepicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/treepicker/treepicker.html @@ -75,6 +75,7 @@ start-node-id="vm.startNodeId" on-select="vm.selectListViewNode(node)" on-close="vm.closeMiniListView()" + on-items-loaded="vm.listViewItemsLoaded(items)" entity-type-filter="vm.filter"> diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/usergrouppicker/usergrouppicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/usergrouppicker/usergrouppicker.html index e2ae1ab524..b82b919f9f 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/usergrouppicker/usergrouppicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/usergrouppicker/usergrouppicker.html @@ -13,10 +13,10 @@ - + - + - + - + - No user groups have been added + No user groups have been added - + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/changepassword/changepassword.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/changepassword/changepassword.html new file mode 100644 index 0000000000..db69a32f7f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/changepassword/changepassword.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/confirm/confirm.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/confirm/confirm.html new file mode 100644 index 0000000000..79dd6bd4f6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/confirm/confirm.html @@ -0,0 +1,13 @@ +
      + +
      + {{model.confirmMessage}} +
      + +

      {{model.content}}

      + +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/itempicker/itempicker.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/itempicker/itempicker.html index a9f57fe304..715dc12a07 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/itempicker/itempicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/itempicker/itempicker.html @@ -19,14 +19,13 @@
      @@ -35,15 +34,13 @@
      -
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html index 4af8c83983..531feef892 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.html @@ -26,8 +26,7 @@ action="togglePasswordFields()" button-style="action" label="Change password" - label-key="general_changePassword" - button-style="success"> + label-key="general_changePassword"> -
      + - + + Link your {{login.caption}} account +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbEmailMarketing/emails/emails.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbEmailMarketing/emails/emails.controller.js new file mode 100644 index 0000000000..8ecc737278 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbEmailMarketing/emails/emails.controller.js @@ -0,0 +1,24 @@ +(function () { + "use strict"; + + function EmailsController($scope, userService) { + + var vm = this; + + vm.optIn = function() { + // Get the current user in backoffice + userService.getCurrentUser().then(function(user){ + // Send this user along to opt in + // It's a fire & forget - not sure we need to check the response + userService.addUserToEmailMarketing(user); + }); + + // Mark Tour as complete + // This is also can help us indicate that the user accepted + // Where disabled is set if user closes modal or chooses NO + $scope.model.completeTour(); + } + } + + angular.module("umbraco").controller("Umbraco.Tours.UmbEmailMarketing.EmailsController", EmailsController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/tours/umbEmailMarketing/emails/emails.html b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbEmailMarketing/emails/emails.html new file mode 100644 index 0000000000..887624ed05 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/common/tours/umbEmailMarketing/emails/emails.html @@ -0,0 +1,26 @@ +
      + + + +

      {{ model.currentStep.title }}

      + +
      + +
      +
      +
      + + +
      + paperplane +
      +
      + +
      + + +
      + +
      + +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-app-header.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-app-header.html index 93c3a9b50d..c6c4f98e25 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-app-header.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-app-header.html @@ -2,7 +2,6 @@
      - @@ -11,19 +10,26 @@
      • -
      • -
      • -
      @@ -152,13 +152,13 @@
      - - + +
      - - + +
      -

      +

      An email will be sent to the address specified with a link to reset your password

      - - + +
      @@ -198,9 +198,11 @@
      -
      + +

      +
      @@ -220,13 +222,13 @@
      - - + +
      - - + +
      diff --git a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-navigation.html b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-navigation.html index 737253feb2..c133d5d38b 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/application/umb-navigation.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/application/umb-navigation.html @@ -1,4 +1,4 @@ -
      +
    diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-content-header.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-content-header.html index 37f363f50d..8496aab80c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-content-header.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-content-header.html @@ -3,38 +3,46 @@
    - +
    +
    +

    + {{a11yMessage}} +

    +
    + - + - + {{vm.currentVariant.language.name}} @@ -42,10 +50,10 @@ - +
    Open in split view
    diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html index cc2ef0f2fa..e1bc01a7a1 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html @@ -1,32 +1,43 @@ 
    - -
    + +
    - +
    -
    - -
    - -
    -
    +
    +
    +

    + {{accessibility.a11yMessage}} +

    +
    + + aria-required="true" + aria-invalid="{{contentForm.headerNameForm.headerName.$invalid ? true : false}}" + autocomplete="off" + maxlength="255"/> @@ -61,7 +76,7 @@ localize="placeholder" placeholder="@placeholders_enterDescription" ng-if="!hideDescription && !descriptionLocked" - ng-model="$parent.description" /> + ng-model="$parent.description"/>
    {{ description }}
    diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-menu.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-menu.html index c621670462..fe90fef07a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-menu.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-menu.html @@ -5,15 +5,18 @@ button-style="white" action="dropdown.isOpen = !dropdown.isOpen" label-key="general_actions" - show-caret="true"> + show-caret="true" + has-popup="true" + is-expanded="dropdown.isOpen" + > - + - - + diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-navigation-item.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-navigation-item.html index 68a3da435b..d743907d07 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-navigation-item.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-navigation-item.html @@ -1,19 +1,19 @@ - - + ng-class="{'is-active': vm.item.active, '-has-error': vm.item.hasError}" + class="umb-sub-views-nav-item__action umb-outline umb-outline--thin"> + {{ vm.item.name }}
    {{vm.item.badge.count}}
    -
    + diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editors.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editors.html index 99f9e96765..2f1286b090 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editors.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editors.html @@ -3,10 +3,11 @@
    + diff --git a/src/Umbraco.Web.UI.Client/src/views/components/forms/umb-radiobutton.html b/src/Umbraco.Web.UI.Client/src/views/components/forms/umb-radiobutton.html index 877e55a1c5..9ee50bcae1 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/forms/umb-radiobutton.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/forms/umb-radiobutton.html @@ -1,13 +1,16 @@ -
    /// [HttpGet] + [StatusCodeResult(System.Net.HttpStatusCode.ServiceUnavailable)] public async Task AuthorizeUpgrade() { return await RenderDefaultOrProcessExternalLoginAsync( @@ -189,7 +190,7 @@ namespace Umbraco.Web.Editors .ToDictionary(pv => pv.Key, pv => pv.ToDictionary(pve => pve.valueAlias, pve => pve.value)); - return new JsonNetResult { Data = nestedDictionary, Formatting = Formatting.Indented }; + return new JsonNetResult { Data = nestedDictionary, Formatting = Formatting.None }; } /// @@ -236,7 +237,7 @@ namespace Umbraco.Web.Editors GetAssetList, new TimeSpan(0, 2, 0)); - return new JsonNetResult { Data = result, Formatting = Formatting.Indented }; + return new JsonNetResult { Data = result, Formatting = Formatting.None }; } [UmbracoAuthorize(Order = 0)] @@ -244,7 +245,7 @@ namespace Umbraco.Web.Editors public JsonNetResult GetGridConfig() { var gridConfig = Current.Configs.Grids(); - return new JsonNetResult { Data = gridConfig.EditorsConfig.Editors, Formatting = Formatting.Indented }; + return new JsonNetResult { Data = gridConfig.EditorsConfig.Editors, Formatting = Formatting.None }; } diff --git a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs index 58f49f4261..dc46a97df4 100644 --- a/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs +++ b/src/Umbraco.Web/Editors/BackOfficeServerVariables.cs @@ -309,7 +309,11 @@ namespace Umbraco.Web.Editors { "webProfilingBaseUrl", _urlHelper.GetUmbracoApiServiceBaseUrl( controller => controller.GetStatus()) - } + }, + { + "tinyMceApiBaseUrl", _urlHelper.GetUmbracoApiServiceBaseUrl( + controller => controller.UploadImage()) + }, } }, { diff --git a/src/Umbraco.Web/Editors/Binders/ContentItemBinder.cs b/src/Umbraco.Web/Editors/Binders/ContentItemBinder.cs index 11a876345d..a6cba6ce41 100644 --- a/src/Umbraco.Web/Editors/Binders/ContentItemBinder.cs +++ b/src/Umbraco.Web/Editors/Binders/ContentItemBinder.cs @@ -49,18 +49,15 @@ namespace Umbraco.Web.Editors.Binders { foreach (var variant in model.Variants) { - if (variant.Culture.IsNullOrWhiteSpace()) - { - //map the property dto collection (no culture is passed to the mapping context so it will be invariant) - variant.PropertyCollectionDto = Current.Mapper.Map(model.PersistedContent); - } - else - { - //map the property dto collection with the culture of the current variant - variant.PropertyCollectionDto = Current.Mapper.Map( - model.PersistedContent, - context => context.SetCulture(variant.Culture)); - } + //map the property dto collection with the culture of the current variant + variant.PropertyCollectionDto = Current.Mapper.Map( + model.PersistedContent, + context => + { + // either of these may be null and that is ok, if it's invariant they will be null which is what is expected + context.SetCulture(variant.Culture); + context.SetSegment(variant.Segment); + }); //now map all of the saved values to the dto _modelBinderHelper.MapPropertyValuesFromSaved(variant, variant.PropertyCollectionDto); @@ -87,6 +84,5 @@ namespace Umbraco.Web.Editors.Binders model.ParentId, contentType); } - } } diff --git a/src/Umbraco.Web/Editors/ContentController.cs b/src/Umbraco.Web/Editors/ContentController.cs index 4e420206d3..f5d72894ca 100644 --- a/src/Umbraco.Web/Editors/ContentController.cs +++ b/src/Umbraco.Web/Editors/ContentController.cs @@ -303,7 +303,7 @@ namespace Umbraco.Web.Editors } /// - /// Gets the content json for the content id + /// Gets the content json for the content guid /// /// /// @@ -323,7 +323,7 @@ namespace Umbraco.Web.Editors } /// - /// Gets the content json for the content id + /// Gets the content json for the content udi /// /// /// @@ -341,7 +341,7 @@ namespace Umbraco.Web.Editors } /// - /// Gets an empty content item for the + /// Gets an empty content item for the document type. /// /// /// @@ -1679,9 +1679,9 @@ namespace Umbraco.Web.Editors throw new HttpResponseException(response); } - var permission = Services.UserService.GetPermissions(Security.CurrentUser, node.Path); + var assignedPermissions = Services.UserService.GetAssignedPermissions(Security.CurrentUser, node.Id); - if (permission.AssignedPermissions.Contains(ActionAssignDomain.ActionLetter.ToString(), StringComparer.Ordinal) == false) + if (assignedPermissions.Contains(ActionAssignDomain.ActionLetter.ToString(), StringComparer.Ordinal) == false) { var response = Request.CreateResponse(HttpStatusCode.BadRequest); response.Content = new StringContent("You do not have permission to assign domains on that node."); @@ -1836,8 +1836,13 @@ namespace Umbraco.Web.Editors /// private void MapValuesForPersistence(ContentItemSave contentSave) { - // inline method to determine if a property type varies - bool Varies(Property property) => property.PropertyType.VariesByCulture(); + // inline method to determine the culture and segment to persist the property + (string culture, string segment) PropertyCultureAndSegment(Property property, ContentVariantSave variant) + { + var culture = property.PropertyType.VariesByCulture() ? variant.Culture : null; + var segment = property.PropertyType.VariesBySegment() ? variant.Segment : null; + return (culture, segment); + } var variantIndex = 0; @@ -1876,8 +1881,18 @@ namespace Umbraco.Web.Editors MapPropertyValuesForPersistence( contentSave, propertyCollection, - (save, property) => Varies(property) ? property.GetValue(variant.Culture) : property.GetValue(), //get prop val - (save, property, v) => { if (Varies(property)) property.SetValue(v, variant.Culture); else property.SetValue(v); }, //set prop val + (save, property) => + { + // Get property value + (var culture, var segment) = PropertyCultureAndSegment(property, variant); + return property.GetValue(culture, segment); + }, + (save, property, v) => + { + // Set property value + (var culture, var segment) = PropertyCultureAndSegment(property, variant); + property.SetValue(v, culture, segment); + }, variant.Culture); variantIndex++; @@ -1926,10 +1941,8 @@ namespace Umbraco.Web.Editors } if (model.ParentId < 0) { - //cannot move if the content item is not allowed at the root unless there are - //none allowed at root (in which case all should be allowed at root) - var contentTypeService = Services.ContentTypeService; - if (toMove.ContentType.AllowedAsRoot == false && contentTypeService.GetAll().Any(ct => ct.AllowedAsRoot)) + //cannot move if the content item is not allowed at the root + if (toMove.ContentType.AllowedAsRoot == false) { throw new HttpResponseException( Request.CreateNotificationValidationErrorResponse( @@ -2297,7 +2310,7 @@ namespace Umbraco.Web.Editors } var entry = Services.PublicAccessService.GetEntryForContent(content); - if (entry == null) + if (entry == null || entry.ProtectedNodeId != content.Id) { return Request.CreateResponse(HttpStatusCode.OK); } @@ -2379,7 +2392,7 @@ namespace Umbraco.Web.Editors var entry = Services.PublicAccessService.GetEntryForContent(content); - if (entry == null) + if (entry == null || entry.ProtectedNodeId != content.Id) { entry = new PublicAccessEntry(content, loginPage, errorPage, new List()); diff --git a/src/Umbraco.Web/Editors/ContentTypeController.cs b/src/Umbraco.Web/Editors/ContentTypeController.cs index cf8b6c3859..9c248f186b 100644 --- a/src/Umbraco.Web/Editors/ContentTypeController.cs +++ b/src/Umbraco.Web/Editors/ContentTypeController.cs @@ -17,9 +17,11 @@ using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Editors; +using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.Packaging; using Umbraco.Core.Persistence; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Scoping; using Umbraco.Core.Services; using Umbraco.Web.Models; using Umbraco.Web.Models.ContentEditing; @@ -46,6 +48,7 @@ namespace Umbraco.Web.Editors { private readonly IEntityXmlSerializer _serializer; private readonly PropertyEditorCollection _propertyEditors; + private readonly IScopeProvider _scopeProvider; public ContentTypeController(IEntityXmlSerializer serializer, ICultureDictionaryFactory cultureDictionaryFactory, @@ -53,11 +56,13 @@ namespace Umbraco.Web.Editors IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, PropertyEditorCollection propertyEditors, ServiceContext services, AppCaches appCaches, - IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) + IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper, + IScopeProvider scopeProvider) : base(cultureDictionaryFactory, globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) { _serializer = serializer; _propertyEditors = propertyEditors; + _scopeProvider = scopeProvider; } public int GetCount() @@ -65,6 +70,13 @@ namespace Umbraco.Web.Editors return Services.ContentTypeService.Count(); } + [HttpGet] + [UmbracoTreeAuthorize(Constants.Trees.DocumentTypes)] + public bool HasContentNodes(int id) + { + return Services.ContentTypeService.HasContentNodes(id); + } + public DocumentTypeDisplay GetById(int id) { var ct = Services.ContentTypeService.Get(id); @@ -132,7 +144,7 @@ namespace Umbraco.Web.Editors [HttpPost] public HttpResponseMessage GetAvailableCompositeContentTypes(GetAvailableCompositionsFilter filter) { - var result = PerformGetAvailableCompositeContentTypes(filter.ContentTypeId, UmbracoObjectTypes.DocumentType, filter.FilterContentTypes, filter.FilterPropertyTypes) + var result = PerformGetAvailableCompositeContentTypes(filter.ContentTypeId, UmbracoObjectTypes.DocumentType, filter.FilterContentTypes, filter.FilterPropertyTypes, filter.IsElement) .Select(x => new { contentType = x.Item1, @@ -409,11 +421,7 @@ namespace Umbraco.Web.Editors IEnumerable types; if (contentId == Constants.System.Root) { - var allContentTypes = Services.ContentTypeService.GetAll().ToList(); - bool AllowedAsRoot(IContentType x) => x.AllowedAsRoot; - types = allContentTypes.Any(AllowedAsRoot) - ? allContentTypes.Where(AllowedAsRoot).ToList() - : allContentTypes; + types = Services.ContentTypeService.GetAll().Where(x => x.AllowedAsRoot).ToList(); } else { @@ -424,11 +432,11 @@ namespace Umbraco.Web.Editors } var contentType = Services.ContentTypeBaseServices.GetContentTypeOf(contentItem); - var ids = contentType.AllowedContentTypes.Select(x => x.Id.Value).ToArray(); + var ids = contentType.AllowedContentTypes.OrderBy(c => c.SortOrder).Select(x => x.Id.Value).ToArray(); if (ids.Any() == false) return Enumerable.Empty(); - types = Services.ContentTypeService.GetAll(ids).ToList(); + types = Services.ContentTypeService.GetAll(ids).OrderBy(c => ids.IndexOf(c.Id)).ToList(); } var basics = types.Where(type => type.IsElement == false).Select(Mapper.Map).ToList(); @@ -451,7 +459,7 @@ namespace Umbraco.Web.Editors } } - return basics; + return basics.OrderBy(c => contentId == Constants.System.Root ? c.Name : string.Empty); } /// @@ -520,7 +528,7 @@ namespace Umbraco.Web.Editors } var dataInstaller = new PackageDataInstallation(Logger, Services.FileService, Services.MacroService, Services.LocalizationService, - Services.DataTypeService, Services.EntityService, Services.ContentTypeService, Services.ContentService, _propertyEditors); + Services.DataTypeService, Services.EntityService, Services.ContentTypeService, Services.ContentService, _propertyEditors, _scopeProvider); var xd = new XmlDocument {XmlResolver = null}; xd.Load(filePath); diff --git a/src/Umbraco.Web/Editors/ContentTypeControllerBase.cs b/src/Umbraco.Web/Editors/ContentTypeControllerBase.cs index 9b8a098e21..2b3990c7a1 100644 --- a/src/Umbraco.Web/Editors/ContentTypeControllerBase.cs +++ b/src/Umbraco.Web/Editors/ContentTypeControllerBase.cs @@ -53,11 +53,13 @@ namespace Umbraco.Web.Editors /// be looked up via the db, they need to be passed in. /// /// + /// Wether the composite content types should be applicable for an element type /// protected IEnumerable> PerformGetAvailableCompositeContentTypes(int contentTypeId, UmbracoObjectTypes type, string[] filterContentTypes, - string[] filterPropertyTypes) + string[] filterPropertyTypes, + bool isElement) { IContentTypeComposition source = null; @@ -98,7 +100,7 @@ namespace Umbraco.Web.Editors throw new ArgumentOutOfRangeException("The entity type was not a content type"); } - var availableCompositions = Services.ContentTypeService.GetAvailableCompositeContentTypes(source, allContentTypes, filterContentTypes, filterPropertyTypes); + var availableCompositions = Services.ContentTypeService.GetAvailableCompositeContentTypes(source, allContentTypes, filterContentTypes, filterPropertyTypes, isElement); diff --git a/src/Umbraco.Web/Editors/DashboardController.cs b/src/Umbraco.Web/Editors/DashboardController.cs index 75fa8476e7..eef0b5df93 100644 --- a/src/Umbraco.Web/Editors/DashboardController.cs +++ b/src/Umbraco.Web/Editors/DashboardController.cs @@ -140,7 +140,7 @@ namespace Umbraco.Web.Editors break; case "OUR": - urlPrefix = "https://our.umbraco.org/"; + urlPrefix = "https://our.umbraco.com/"; break; case "COM": diff --git a/src/Umbraco.Web/Editors/DataTypeController.cs b/src/Umbraco.Web/Editors/DataTypeController.cs index 30c4162252..5f5f5104cb 100644 --- a/src/Umbraco.Web/Editors/DataTypeController.cs +++ b/src/Umbraco.Web/Editors/DataTypeController.cs @@ -20,6 +20,7 @@ using Umbraco.Web.Composing; using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Persistence; +using System.Web.Http.Controllers; namespace Umbraco.Web.Editors { @@ -34,6 +35,7 @@ namespace Umbraco.Web.Editors [PluginController("UmbracoApi")] [UmbracoTreeAuthorize(Constants.Trees.DataTypes, Constants.Trees.DocumentTypes, Constants.Trees.MediaTypes, Constants.Trees.MemberTypes)] [EnableOverrideAuthorization] + [DataTypeControllerConfiguration] public class DataTypeController : BackOfficeNotificationsController { private readonly PropertyEditorCollection _propertyEditors; @@ -44,6 +46,19 @@ namespace Umbraco.Web.Editors _propertyEditors = propertyEditors; } + /// + /// Configures this controller with a custom action selector + /// + private class DataTypeControllerConfigurationAttribute : Attribute, IControllerConfiguration + { + public void Initialize(HttpControllerSettings controllerSettings, HttpControllerDescriptor controllerDescriptor) + { + controllerSettings.Services.Replace(typeof(IHttpActionSelector), new ParameterSwapControllerActionSelector( + new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetById", "id", typeof(int), typeof(Guid), typeof(Udi)) + )); + } + } + /// /// Gets data type by name /// @@ -70,6 +85,40 @@ namespace Umbraco.Web.Editors return Mapper.Map(dataType); } + /// + /// Gets the datatype json for the datatype guid + /// + /// + /// + public DataTypeDisplay GetById(Guid id) + { + var dataType = Services.DataTypeService.GetDataType(id); + if (dataType == null) + { + throw new HttpResponseException(HttpStatusCode.NotFound); + } + return Mapper.Map(dataType); + } + + /// + /// Gets the datatype json for the datatype udi + /// + /// + /// + public DataTypeDisplay GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) + throw new HttpResponseException(HttpStatusCode.NotFound); + + var dataType = Services.DataTypeService.GetDataType(guidUdi.Guid); + if (dataType == null) + { + throw new HttpResponseException(HttpStatusCode.NotFound); + } + return Mapper.Map(dataType); + } + /// /// Deletes a data type with a given ID /// @@ -283,6 +332,60 @@ namespace Umbraco.Web.Editors : Request.CreateNotificationValidationErrorResponse(result.Exception.Message); } + /// + /// Returns the references (usages) for the data type + /// + /// + /// + public DataTypeReferences GetReferences(int id) + { + var result = new DataTypeReferences(); + var usages = Services.DataTypeService.GetReferences(id); + + foreach(var groupOfEntityType in usages.GroupBy(x => x.Key.EntityType)) + { + //get all the GUIDs for the content types to find + var guidsAndPropertyAliases = groupOfEntityType.ToDictionary(i => ((GuidUdi)i.Key).Guid, i => i.Value); + + if (groupOfEntityType.Key == ObjectTypes.GetUdiType(UmbracoObjectTypes.DocumentType)) + result.DocumentTypes = GetContentTypeUsages(Services.ContentTypeService.GetAll(guidsAndPropertyAliases.Keys), guidsAndPropertyAliases); + else if (groupOfEntityType.Key == ObjectTypes.GetUdiType(UmbracoObjectTypes.MediaType)) + result.MediaTypes = GetContentTypeUsages(Services.MediaTypeService.GetAll(guidsAndPropertyAliases.Keys), guidsAndPropertyAliases); + else if (groupOfEntityType.Key == ObjectTypes.GetUdiType(UmbracoObjectTypes.MemberType)) + result.MemberTypes = GetContentTypeUsages(Services.MemberTypeService.GetAll(guidsAndPropertyAliases.Keys), guidsAndPropertyAliases); + } + + return result; + } + + /// + /// Maps the found content types and usages to the resulting model + /// + /// + /// + /// + private IEnumerable GetContentTypeUsages( + IEnumerable cts, + IReadOnlyDictionary> usages) + { + return cts.Select(x => new DataTypeReferences.ContentTypeReferences + { + Id = x.Id, + Key = x.Key, + Alias = x.Alias, + Icon = x.Icon, + Name = x.Name, + Udi = new GuidUdi(ObjectTypes.GetUdiType(UmbracoObjectTypes.DocumentType), x.Key), + //only select matching properties + Properties = x.PropertyTypes.Where(p => usages[x.Key].InvariantContains(p.Alias)) + .Select(p => new DataTypeReferences.ContentTypeReferences.PropertyTypeReferences + { + Alias = p.Alias, + Name = p.Name + }) + }); + } + #region ReadOnly actions to return basic data - allow access for: content ,media, members, settings, developer /// /// Gets the content json for all data types diff --git a/src/Umbraco.Web/Editors/EditorValidatorCollection.cs b/src/Umbraco.Web/Editors/EditorValidatorCollection.cs index 6fc6bb5de2..0e42b0027c 100644 --- a/src/Umbraco.Web/Editors/EditorValidatorCollection.cs +++ b/src/Umbraco.Web/Editors/EditorValidatorCollection.cs @@ -6,7 +6,7 @@ using Umbraco.Core.Composing; namespace Umbraco.Web.Editors { - internal class EditorValidatorCollection : BuilderCollectionBase + public class EditorValidatorCollection : BuilderCollectionBase { public EditorValidatorCollection(IEnumerable items) : base(items) diff --git a/src/Umbraco.Web/Editors/EditorValidatorCollectionBuilder.cs b/src/Umbraco.Web/Editors/EditorValidatorCollectionBuilder.cs index b3b7bab1a5..994a813cf4 100644 --- a/src/Umbraco.Web/Editors/EditorValidatorCollectionBuilder.cs +++ b/src/Umbraco.Web/Editors/EditorValidatorCollectionBuilder.cs @@ -2,7 +2,7 @@ namespace Umbraco.Web.Editors { - internal class EditorValidatorCollectionBuilder : LazyCollectionBuilderBase + public class EditorValidatorCollectionBuilder : LazyCollectionBuilderBase { protected override EditorValidatorCollectionBuilder This => this; } diff --git a/src/Umbraco.Web/Editors/EntityController.cs b/src/Umbraco.Web/Editors/EntityController.cs index 5e094534d2..a894b681f0 100644 --- a/src/Umbraco.Web/Editors/EntityController.cs +++ b/src/Umbraco.Web/Editors/EntityController.cs @@ -206,22 +206,54 @@ namespace Umbraco.Web.Editors throw new HttpResponseException(HttpStatusCode.NotFound); } + /// + /// Gets the url of an entity + /// + /// UDI of the entity to fetch URL for + /// The culture to fetch the URL for + /// The URL or path to the item + public HttpResponseMessage GetUrl(Udi udi, string culture = "*") + { + var intId = Services.EntityService.GetId(udi); + if (!intId.Success) + throw new HttpResponseException(HttpStatusCode.NotFound); + UmbracoEntityTypes entityType; + switch(udi.EntityType) + { + case Constants.UdiEntityType.Document: + entityType = UmbracoEntityTypes.Document; + break; + case Constants.UdiEntityType.Media: + entityType = UmbracoEntityTypes.Media; + break; + case Constants.UdiEntityType.Member: + entityType = UmbracoEntityTypes.Member; + break; + default: + throw new HttpResponseException(HttpStatusCode.NotFound); + } + return GetUrl(intId.Result, entityType, culture); + } + /// /// Gets the url of an entity /// /// Int id of the entity to fetch URL for /// The type of entity such as Document, Media, Member + /// The culture to fetch the URL for /// The URL or path to the item /// /// We are not restricting this with security because there is no sensitive data /// - public HttpResponseMessage GetUrl(int id, UmbracoEntityTypes type) + public HttpResponseMessage GetUrl(int id, UmbracoEntityTypes type, string culture = null) { + culture = culture ?? ClientCulture(); + var returnUrl = string.Empty; if (type == UmbracoEntityTypes.Document) { - var foundUrl = UmbracoContext.Url(id); + var foundUrl = UmbracoContext.Url(id, culture); if (string.IsNullOrEmpty(foundUrl) == false && foundUrl != "#") { returnUrl = foundUrl; @@ -300,7 +332,9 @@ namespace Umbraco.Web.Editors [HttpGet] public UrlAndAnchors GetUrlAndAnchors(int id, string culture = "*") { - var url = UmbracoContext.UrlProvider.GetUrl(id); + culture = culture ?? ClientCulture(); + + var url = UmbracoContext.UrlProvider.GetUrl(id, culture: culture); var anchorValues = Services.ContentService.GetAnchorValuesFromRTEs(id, culture); return new UrlAndAnchors(url, anchorValues); } @@ -659,6 +693,9 @@ namespace Umbraco.Web.Editors if (pageSize <= 0) throw new HttpResponseException(HttpStatusCode.NotFound); + // re-normalize since NULL can be passed in + filter = filter ?? string.Empty; + var objectType = ConvertToObjectType(type); if (objectType.HasValue) { diff --git a/src/Umbraco.Web/Editors/ExamineManagementController.cs b/src/Umbraco.Web/Editors/ExamineManagementController.cs index 7c040ae508..0953b41cac 100644 --- a/src/Umbraco.Web/Editors/ExamineManagementController.cs +++ b/src/Umbraco.Web/Editors/ExamineManagementController.cs @@ -177,8 +177,12 @@ namespace Umbraco.Web.Editors var indexName = index.Name; if (!(index is IIndexDiagnostics indexDiag)) - indexDiag = new GenericIndexDiagnostics(index); - + { + if (index is LuceneIndex luceneIndex) + indexDiag = new LuceneIndexDiagnostics(luceneIndex, Logger); + else + indexDiag = new GenericIndexDiagnostics(index); + } var isHealth = indexDiag.IsHealthy(); var properties = new Dictionary diff --git a/src/Umbraco.Web/Editors/Filters/ContentModelValidator.cs b/src/Umbraco.Web/Editors/Filters/ContentModelValidator.cs index 4acf0c948e..0d77b35528 100644 --- a/src/Umbraco.Web/Editors/Filters/ContentModelValidator.cs +++ b/src/Umbraco.Web/Editors/Filters/ContentModelValidator.cs @@ -5,10 +5,10 @@ using System.Net; using System.Net.Http; using System.Web.Http.Controllers; using System.Web.Http.ModelBinding; -using Umbraco.Core; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; namespace Umbraco.Web.Editors.Filters @@ -43,8 +43,11 @@ namespace Umbraco.Web.Editors.Filters where TModelSave: IContentSave where TModelWithProperties : IContentProperties { - protected ContentModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor) : base(logger, umbracoContextAccessor) + private readonly ILocalizedTextService _textService; + + protected ContentModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService) : base(logger, umbracoContextAccessor) { + _textService = textService ?? throw new ArgumentNullException(nameof(textService)); } /// @@ -122,6 +125,18 @@ namespace Umbraco.Web.Editors.Filters { var properties = modelWithProperties.Properties.ToDictionary(x => x.Alias, x => x); + // Retrieve default messages used for required and regex validatation. We'll replace these + // if set with custom ones if they've been provided for a given property. + var requiredDefaultMessages = new[] + { + _textService.Localize("validation", "invalidNull"), + _textService.Localize("validation", "invalidEmpty") + }; + var formatDefaultMessages = new[] + { + _textService.Localize("validation", "invalidPattern"), + }; + foreach (var p in dto.Properties) { var editor = p.PropertyEditor; @@ -141,7 +156,7 @@ namespace Umbraco.Web.Editors.Filters var postedValue = postedProp.Value; - ValidatePropertyValue(model, modelWithProperties, editor, p, postedValue, modelState); + ValidatePropertyValue(model, modelWithProperties, editor, p, postedValue, modelState, requiredDefaultMessages, formatDefaultMessages); } @@ -157,22 +172,34 @@ namespace Umbraco.Web.Editors.Filters /// /// /// + /// + /// protected virtual void ValidatePropertyValue( TModelSave model, TModelWithProperties modelWithProperties, IDataEditor editor, ContentPropertyDto property, object postedValue, - ModelStateDictionary modelState) + ModelStateDictionary modelState, + string[] requiredDefaultMessages, + string[] formatDefaultMessages) { - // validate var valueEditor = editor.GetValueEditor(property.DataType.Configuration); foreach (var r in valueEditor.Validate(postedValue, property.IsRequired, property.ValidationRegExp)) { + // If we've got custom error messages, we'll replace the default ones that will have been applied in the call to Validate(). + if (property.IsRequired && !string.IsNullOrWhiteSpace(property.IsRequiredMessage) && requiredDefaultMessages.Contains(r.ErrorMessage, StringComparer.OrdinalIgnoreCase)) + { + r.ErrorMessage = property.IsRequiredMessage; + } + + if (!string.IsNullOrWhiteSpace(property.ValidationRegExp) && !string.IsNullOrWhiteSpace(property.ValidationRegExpMessage) && formatDefaultMessages.Contains(r.ErrorMessage, StringComparer.OrdinalIgnoreCase)) + { + r.ErrorMessage = property.ValidationRegExpMessage; + } + modelState.AddPropertyError(r, property.Alias, property.Culture); } } - - } } diff --git a/src/Umbraco.Web/Editors/Filters/ContentSaveModelValidator.cs b/src/Umbraco.Web/Editors/Filters/ContentSaveModelValidator.cs index b74cd71f11..39bd6ab0f4 100644 --- a/src/Umbraco.Web/Editors/Filters/ContentSaveModelValidator.cs +++ b/src/Umbraco.Web/Editors/Filters/ContentSaveModelValidator.cs @@ -1,8 +1,6 @@ -using System.Web.Http.ModelBinding; -using Umbraco.Core; -using Umbraco.Core.Logging; +using Umbraco.Core.Logging; using Umbraco.Core.Models; -using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; namespace Umbraco.Web.Editors.Filters @@ -12,7 +10,7 @@ namespace Umbraco.Web.Editors.Filters /// internal class ContentSaveModelValidator : ContentModelValidator { - public ContentSaveModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor) : base(logger, umbracoContextAccessor) + public ContentSaveModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService) : base(logger, umbracoContextAccessor, textService) { } } diff --git a/src/Umbraco.Web/Editors/Filters/ContentSaveValidationAttribute.cs b/src/Umbraco.Web/Editors/Filters/ContentSaveValidationAttribute.cs index 18fd334b1c..fbce2d0414 100644 --- a/src/Umbraco.Web/Editors/Filters/ContentSaveValidationAttribute.cs +++ b/src/Umbraco.Web/Editors/Filters/ContentSaveValidationAttribute.cs @@ -23,28 +23,30 @@ namespace Umbraco.Web.Editors.Filters /// internal sealed class ContentSaveValidationAttribute : ActionFilterAttribute { - public ContentSaveValidationAttribute(): this(Current.Logger, Current.UmbracoContextAccessor, Current.Services.ContentService, Current.Services.UserService, Current.Services.EntityService) + private readonly ILogger _logger; + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly ILocalizedTextService _textService; + private readonly IContentService _contentService; + private readonly IUserService _userService; + private readonly IEntityService _entityService; + + public ContentSaveValidationAttribute(): this(Current.Logger, Current.UmbracoContextAccessor, Current.Services.TextService, Current.Services.ContentService, Current.Services.UserService, Current.Services.EntityService) { } - public ContentSaveValidationAttribute(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, IContentService contentService, IUserService userService, IEntityService entityService) + public ContentSaveValidationAttribute(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService, IContentService contentService, IUserService userService, IEntityService entityService) { - _logger = logger; - _umbracoContextAccessor = umbracoContextAccessor; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); + _textService = textService ?? throw new ArgumentNullException(nameof(textService)); _contentService = contentService ?? throw new ArgumentNullException(nameof(contentService)); _userService = userService ?? throw new ArgumentNullException(nameof(userService)); _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); } - private readonly ILogger _logger; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly IContentService _contentService; - private readonly IUserService _userService; - private readonly IEntityService _entityService; - public override void OnActionExecuting(HttpActionContext actionContext) { var model = (ContentItemSave)actionContext.ActionArguments["contentItem"]; - var contentItemValidator = new ContentSaveModelValidator(_logger, _umbracoContextAccessor); + var contentItemValidator = new ContentSaveModelValidator(_logger, _umbracoContextAccessor, _textService); if (!ValidateAtLeastOneVariantIsBeingSaved(model, actionContext)) return; if (!contentItemValidator.ValidateExistingContent(model, actionContext)) return; diff --git a/src/Umbraco.Web/Editors/Filters/MediaItemSaveValidationAttribute.cs b/src/Umbraco.Web/Editors/Filters/MediaItemSaveValidationAttribute.cs index 7351c476e4..449ef95675 100644 --- a/src/Umbraco.Web/Editors/Filters/MediaItemSaveValidationAttribute.cs +++ b/src/Umbraco.Web/Editors/Filters/MediaItemSaveValidationAttribute.cs @@ -1,7 +1,6 @@ -using System.Linq; +using System; using System.Net; using System.Net.Http; -using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.Filters; using Umbraco.Core; @@ -21,25 +20,27 @@ namespace Umbraco.Web.Editors.Filters { private readonly ILogger _logger; private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly ILocalizedTextService _textService; private readonly IMediaService _mediaService; private readonly IEntityService _entityService; - public MediaItemSaveValidationAttribute() : this(Current.Logger, Current.UmbracoContextAccessor, Current.Services.MediaService, Current.Services.EntityService) + public MediaItemSaveValidationAttribute() : this(Current.Logger, Current.UmbracoContextAccessor, Current.Services.TextService, Current.Services.MediaService, Current.Services.EntityService) { } - public MediaItemSaveValidationAttribute(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, IMediaService mediaService, IEntityService entityService) + public MediaItemSaveValidationAttribute(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService, IMediaService mediaService, IEntityService entityService) { - _logger = logger; - _umbracoContextAccessor = umbracoContextAccessor; - _mediaService = mediaService; - _entityService = entityService; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); + _textService = textService ?? throw new ArgumentNullException(nameof(textService)); + _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); + _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); } public override void OnActionExecuting(HttpActionContext actionContext) { var model = (MediaItemSave)actionContext.ActionArguments["contentItem"]; - var contentItemValidator = new MediaSaveModelValidator(_logger, _umbracoContextAccessor); + var contentItemValidator = new MediaSaveModelValidator(_logger, _umbracoContextAccessor, _textService); if (ValidateUserAccess(model, actionContext)) { diff --git a/src/Umbraco.Web/Editors/Filters/MediaSaveModelValidator.cs b/src/Umbraco.Web/Editors/Filters/MediaSaveModelValidator.cs index 83c0885d62..87b55fea76 100644 --- a/src/Umbraco.Web/Editors/Filters/MediaSaveModelValidator.cs +++ b/src/Umbraco.Web/Editors/Filters/MediaSaveModelValidator.cs @@ -1,5 +1,6 @@ using Umbraco.Core.Logging; using Umbraco.Core.Models; +using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; namespace Umbraco.Web.Editors.Filters @@ -9,8 +10,8 @@ namespace Umbraco.Web.Editors.Filters /// internal class MediaSaveModelValidator : ContentModelValidator> { - public MediaSaveModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor) : base(logger, umbracoContextAccessor) + public MediaSaveModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService) : base(logger, umbracoContextAccessor, textService) { } } -} \ No newline at end of file +} diff --git a/src/Umbraco.Web/Editors/Filters/MemberSaveModelValidator.cs b/src/Umbraco.Web/Editors/Filters/MemberSaveModelValidator.cs index 7fc2b1e648..9b47965493 100644 --- a/src/Umbraco.Web/Editors/Filters/MemberSaveModelValidator.cs +++ b/src/Umbraco.Web/Editors/Filters/MemberSaveModelValidator.cs @@ -21,10 +21,10 @@ namespace Umbraco.Web.Editors.Filters { private readonly IMemberTypeService _memberTypeService; - public MemberSaveModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, IMemberTypeService memberTypeService) - : base(logger, umbracoContextAccessor) + public MemberSaveModelValidator(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService, IMemberTypeService memberTypeService) + : base(logger, umbracoContextAccessor, textService) { - _memberTypeService = memberTypeService; + _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); } /// diff --git a/src/Umbraco.Web/Editors/Filters/MemberSaveValidationAttribute.cs b/src/Umbraco.Web/Editors/Filters/MemberSaveValidationAttribute.cs index 04b0112d56..7e2c204596 100644 --- a/src/Umbraco.Web/Editors/Filters/MemberSaveValidationAttribute.cs +++ b/src/Umbraco.Web/Editors/Filters/MemberSaveValidationAttribute.cs @@ -1,4 +1,5 @@ -using System.Web.Http.Controllers; +using System; +using System.Web.Http.Controllers; using System.Web.Http.Filters; using Umbraco.Core.Logging; using Umbraco.Core.Services; @@ -14,23 +15,25 @@ namespace Umbraco.Web.Editors.Filters { private readonly ILogger _logger; private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly ILocalizedTextService _textService; private readonly IMemberTypeService _memberTypeService; public MemberSaveValidationAttribute() - : this(Current.Logger, Current.UmbracoContextAccessor, Current.Services.MemberTypeService) + : this(Current.Logger, Current.UmbracoContextAccessor, Current.Services.TextService, Current.Services.MemberTypeService) { } - public MemberSaveValidationAttribute(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, IMemberTypeService memberTypeService) + public MemberSaveValidationAttribute(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, ILocalizedTextService textService, IMemberTypeService memberTypeService) { - _logger = logger; - _umbracoContextAccessor = umbracoContextAccessor; - _memberTypeService = memberTypeService; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); + _textService = textService ?? throw new ArgumentNullException(nameof(textService)); + _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); } public override void OnActionExecuting(HttpActionContext actionContext) { var model = (MemberSave)actionContext.ActionArguments["contentItem"]; - var contentItemValidator = new MemberSaveModelValidator(_logger, _umbracoContextAccessor, _memberTypeService); + var contentItemValidator = new MemberSaveModelValidator(_logger, _umbracoContextAccessor, _textService, _memberTypeService); //now do each validation step if (contentItemValidator.ValidateExistingContent(model, actionContext)) if (contentItemValidator.ValidateProperties(model, model, actionContext)) diff --git a/src/Umbraco.Web/Editors/Filters/UserGroupValidateAttribute.cs b/src/Umbraco.Web/Editors/Filters/UserGroupValidateAttribute.cs index 1bd7f766d4..78cd8e6a4d 100644 --- a/src/Umbraco.Web/Editors/Filters/UserGroupValidateAttribute.cs +++ b/src/Umbraco.Web/Editors/Filters/UserGroupValidateAttribute.cs @@ -50,6 +50,14 @@ namespace Umbraco.Web.Editors.Filters actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.NotFound, message); return; } + + if (persisted.Alias != userGroupSave.Alias && persisted.IsSystemUserGroup()) + { + var message = $"User group with alias: {persisted.Alias} cannot be changed"; + actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.BadRequest, message); + return; + } + //map the model to the persisted instance Mapper.Map(userGroupSave, persisted); break; diff --git a/src/Umbraco.Web/Editors/HelpController.cs b/src/Umbraco.Web/Editors/HelpController.cs index ccbbcaeee8..39dbbc435c 100644 --- a/src/Umbraco.Web/Editors/HelpController.cs +++ b/src/Umbraco.Web/Editors/HelpController.cs @@ -13,14 +13,23 @@ namespace Umbraco.Web.Editors { var url = string.Format(baseUrl + "/Umbraco/Documentation/Lessons/GetContextHelpDocs?sectionAlias={0}&treeAlias={1}", section, tree); - if (_httpClient == null) - _httpClient = new HttpClient(); + try + { - //fetch dashboard json and parse to JObject - var json = await _httpClient.GetStringAsync(url); - var result = JsonConvert.DeserializeObject>(json); - if (result != null) - return result; + if (_httpClient == null) + _httpClient = new HttpClient(); + + //fetch dashboard json and parse to JObject + var json = await _httpClient.GetStringAsync(url); + var result = JsonConvert.DeserializeObject>(json); + if (result != null) + return result; + + } + catch (HttpRequestException rex) + { + Logger.Info(GetType(), $"Check your network connection, exception: {rex.Message}"); + } return new List(); } diff --git a/src/Umbraco.Web/Editors/IEditorValidator.cs b/src/Umbraco.Web/Editors/IEditorValidator.cs index d469d9d9eb..2d655e3506 100644 --- a/src/Umbraco.Web/Editors/IEditorValidator.cs +++ b/src/Umbraco.Web/Editors/IEditorValidator.cs @@ -14,9 +14,7 @@ namespace Umbraco.Web.Editors // initialized with all IEditorValidator instances // // validation is used exclusively in ContentTypeControllerBase - // the whole thing is internal at the moment, never released - // and, there are no IEditorValidator implementation in Core - // so... this all mechanism is basically useless + // currently the only implementations are for Models Builder. /// /// Provides a general object validator. diff --git a/src/Umbraco.Web/Editors/ImagesController.cs b/src/Umbraco.Web/Editors/ImagesController.cs index b29c166765..db6706d1bb 100644 --- a/src/Umbraco.Web/Editors/ImagesController.cs +++ b/src/Umbraco.Web/Editors/ImagesController.cs @@ -60,8 +60,27 @@ namespace Umbraco.Web.Editors //redirect to ImageProcessor thumbnail with rnd generated from last modified time of original media file var response = Request.CreateResponse(HttpStatusCode.Found); - var imageLastModified = _mediaFileSystem.GetLastModified(imagePath); - response.Headers.Location = new Uri($"{imagePath}?rnd={imageLastModified:yyyyMMddHHmmss}&upscale=false&width={width}&animationprocessmode=first&mode=max", UriKind.Relative); + + DateTimeOffset? imageLastModified = null; + try + { + imageLastModified = _mediaFileSystem.GetLastModified(imagePath); + + } + catch (Exception) + { + // if we get an exception here it's probably because the image path being requested is an image that doesn't exist + // in the local media file system. This can happen if someone is storing an absolute path to an image online, which + // is perfectly legal but in that case the media file system isn't going to resolve it. + // so ignore and we won't set a last modified date. + } + + // TODO: When we abstract imaging for netcore, we are actually just going to be abstracting a URI builder for images, this + // is one of those places where this can be used. + + var rnd = imageLastModified.HasValue ? $"&rnd={imageLastModified:yyyyMMddHHmmss}" : string.Empty; + + response.Headers.Location = new Uri($"{imagePath}?upscale=false&width={width}&animationprocessmode=first&mode=max{rnd}", UriKind.RelativeOrAbsolute); return response; } diff --git a/src/Umbraco.Web/Editors/LanguageController.cs b/src/Umbraco.Web/Editors/LanguageController.cs index 650dcea6e9..cb7fde23db 100644 --- a/src/Umbraco.Web/Editors/LanguageController.cs +++ b/src/Umbraco.Web/Editors/LanguageController.cs @@ -99,24 +99,28 @@ namespace Umbraco.Web.Editors throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); // this is prone to race conditions but the service will not let us proceed anyways - var existing = Services.LocalizationService.GetLanguageByIsoCode(language.IsoCode); + var existingByCulture = Services.LocalizationService.GetLanguageByIsoCode(language.IsoCode); // the localization service might return the generic language even when queried for specific ones (e.g. "da" when queried for "da-DK") // - we need to handle that explicitly - if (existing?.IsoCode != language.IsoCode) + if (existingByCulture?.IsoCode != language.IsoCode) { - existing = null; + existingByCulture = null; } - if (existing != null && language.Id != existing.Id) + if (existingByCulture != null && language.Id != existingByCulture.Id) { //someone is trying to create a language that already exist ModelState.AddModelError("IsoCode", "The language " + language.IsoCode + " already exists"); throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); } - if (existing == null) + var existingById = language.Id != default ? Services.LocalizationService.GetLanguageById(language.Id) : null; + + if (existingById == null) { + //Creating a new lang... + CultureInfo culture; try { @@ -141,38 +145,39 @@ namespace Umbraco.Web.Editors return Mapper.Map(newLang); } - existing.IsMandatory = language.IsMandatory; + existingById.IsMandatory = language.IsMandatory; // note that the service will prevent the default language from being "un-defaulted" // but does not hurt to test here - though the UI should prevent it too - if (existing.IsDefault && !language.IsDefault) + if (existingById.IsDefault && !language.IsDefault) { ModelState.AddModelError("IsDefault", "Cannot un-default the default language."); throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); } - existing.IsDefault = language.IsDefault; - existing.FallbackLanguageId = language.FallbackLanguageId; + existingById.IsDefault = language.IsDefault; + existingById.FallbackLanguageId = language.FallbackLanguageId; + existingById.IsoCode = language.IsoCode; // modifying an existing language can create a fallback, verify // note that the service will check again, dealing with race conditions - if (existing.FallbackLanguageId.HasValue) + if (existingById.FallbackLanguageId.HasValue) { var languages = Services.LocalizationService.GetAllLanguages().ToDictionary(x => x.Id, x => x); - if (!languages.ContainsKey(existing.FallbackLanguageId.Value)) + if (!languages.ContainsKey(existingById.FallbackLanguageId.Value)) { ModelState.AddModelError("FallbackLanguage", "The selected fall back language does not exist."); throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); } - if (CreatesCycle(existing, languages)) + if (CreatesCycle(existingById, languages)) { - ModelState.AddModelError("FallbackLanguage", $"The selected fall back language {languages[existing.FallbackLanguageId.Value].IsoCode} would create a circular path."); + ModelState.AddModelError("FallbackLanguage", $"The selected fall back language {languages[existingById.FallbackLanguageId.Value].IsoCode} would create a circular path."); throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); } } - Services.LocalizationService.Save(existing); - return Mapper.Map(existing); + Services.LocalizationService.Save(existingById); + return Mapper.Map(existingById); } // see LocalizationService diff --git a/src/Umbraco.Web/Editors/LogViewerController.cs b/src/Umbraco.Web/Editors/LogViewerController.cs index d9fcfd108a..d48de8a00a 100644 --- a/src/Umbraco.Web/Editors/LogViewerController.cs +++ b/src/Umbraco.Web/Editors/LogViewerController.cs @@ -130,5 +130,11 @@ namespace Umbraco.Web.Editors { return _logViewer.DeleteSavedSearch(item.Name, item.Query); } + + [HttpGet] + public string GetLogLevel() + { + return _logViewer.GetLogLevel(); + } } } diff --git a/src/Umbraco.Web/Editors/MacroRenderingController.cs b/src/Umbraco.Web/Editors/MacroRenderingController.cs index efad07ce89..7aabff6822 100644 --- a/src/Umbraco.Web/Editors/MacroRenderingController.cs +++ b/src/Umbraco.Web/Editors/MacroRenderingController.cs @@ -135,14 +135,19 @@ namespace Umbraco.Web.Editors // must have an active variation context! _variationContextAccessor.VariationContext = new VariationContext(culture); - var result = Request.CreateResponse(); - //need to create a specific content result formatted as HTML since this controller has been configured - //with only json formatters. - result.Content = new StringContent( - _componentRenderer.RenderMacro(pageId, m.Alias, macroParams).ToString(), - Encoding.UTF8, - "text/html"); - return result; + using (UmbracoContext.ForcedPreview(true)) + { + + var result = Request.CreateResponse(); + //need to create a specific content result formatted as HTML since this controller has been configured + //with only json formatters. + result.Content = new StringContent( + _componentRenderer.RenderMacro(pageId, m.Alias, macroParams).ToString(), + Encoding.UTF8, + "text/html"); + + return result; + } } [HttpPost] diff --git a/src/Umbraco.Web/Editors/MacrosController.cs b/src/Umbraco.Web/Editors/MacrosController.cs index d7d50236d5..38103400d9 100644 --- a/src/Umbraco.Web/Editors/MacrosController.cs +++ b/src/Umbraco.Web/Editors/MacrosController.cs @@ -19,6 +19,7 @@ using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; using Umbraco.Web.WebApi.Filters; using Constants = Umbraco.Core.Constants; +using System.Web.Http.Controllers; namespace Umbraco.Web.Editors { @@ -28,6 +29,7 @@ namespace Umbraco.Web.Editors /// [PluginController("UmbracoApi")] [UmbracoTreeAuthorize(Constants.Trees.Macros)] + [MacrosControllerConfiguration] public class MacrosController : BackOfficeNotificationsController { private readonly IMacroService _macroService; @@ -38,6 +40,19 @@ namespace Umbraco.Web.Editors _macroService = Services.MacroService; } + /// + /// Configures this controller with a custom action selector + /// + private class MacrosControllerConfigurationAttribute : Attribute, IControllerConfiguration + { + public void Initialize(HttpControllerSettings controllerSettings, HttpControllerDescriptor controllerDescriptor) + { + controllerSettings.Services.Replace(typeof(IHttpActionSelector), new ParameterSwapControllerActionSelector( + new ParameterSwapControllerActionSelector.ParameterSwapInfo("GetById", "id", typeof(int), typeof(Guid), typeof(Udi)) + )); + } + } + /// /// Creates a new macro /// @@ -62,6 +77,11 @@ namespace Umbraco.Web.Editors return this.ReturnErrorResponse("Macro with this alias already exists"); } + if (name == null || name.Length > 255) + { + return this.ReturnErrorResponse("Name cannnot be more than 255 characters in length."); + } + try { var macro = new Macro @@ -92,39 +112,43 @@ namespace Umbraco.Web.Editors return this.ReturnErrorResponse($"Macro with id {id} does not exist"); } - var macroDisplay = new MacroDisplay - { - Alias = macro.Alias, - Id = macro.Id, - Key = macro.Key, - Name = macro.Name, - CacheByPage = macro.CacheByPage, - CacheByUser = macro.CacheByMember, - CachePeriod = macro.CacheDuration, - View = macro.MacroSource, - RenderInEditor = !macro.DontRender, - UseInEditor = macro.UseInEditor, - Path = $"-1,{macro.Id}" - }; - - var parameters = new List(); - - foreach (var param in macro.Properties.Values.OrderBy(x => x.SortOrder)) - { - parameters.Add(new MacroParameterDisplay - { - Editor = param.EditorAlias, - Key = param.Alias, - Label = param.Name, - Id = param.Id - }); - } - - macroDisplay.Parameters = parameters; + var macroDisplay = MapToDisplay(macro); return this.Request.CreateResponse(HttpStatusCode.OK, macroDisplay); } + [HttpGet] + public HttpResponseMessage GetById(Guid id) + { + var macro = _macroService.GetById(id); + + if (macro == null) + { + return this.ReturnErrorResponse($"Macro with id {id} does not exist"); + } + + var macroDisplay = MapToDisplay(macro); + + return this.Request.CreateResponse(HttpStatusCode.OK, macroDisplay); + } + + [HttpGet] + public HttpResponseMessage GetById(Udi id) + { + var guidUdi = id as GuidUdi; + if (guidUdi == null) + this.ReturnErrorResponse($"Macro with id {id} does not exist"); + + var macro = _macroService.GetById(guidUdi.Guid); + if (macro == null) + { + return this.ReturnErrorResponse($"Macro with id {id} does not exist"); + } + + var macroDisplay = MapToDisplay(macro); + + return this.Request.CreateResponse(HttpStatusCode.OK, macroDisplay); + } [HttpPost] public HttpResponseMessage DeleteById(int id) @@ -149,6 +173,11 @@ namespace Umbraco.Web.Editors return this.ReturnErrorResponse($"No macro data found in request"); } + if (macroDisplay.Name == null || macroDisplay.Name.Length > 255) + { + return this.ReturnErrorResponse("Name cannnot be more than 255 characters in length."); + } + var macro = _macroService.GetById(int.Parse(macroDisplay.Id.ToString())); if (macro == null) @@ -219,6 +248,39 @@ namespace Umbraco.Web.Editors return this.Request.CreateResponse(HttpStatusCode.OK, Current.ParameterEditors); } + /// + /// Gets the available parameter editors grouped by their group. + /// + /// + /// The . + /// + public HttpResponseMessage GetGroupedParameterEditors() + { + var parameterEditors = Current.ParameterEditors.ToArray(); + + var grouped = parameterEditors + .GroupBy(x => x.Group.IsNullOrWhiteSpace() ? "" : x.Group.ToLower()) + .OrderBy(x => x.Key) + .ToDictionary(group => group.Key, group => group.OrderBy(d => d.Name).AsEnumerable()); + + return this.Request.CreateResponse(HttpStatusCode.OK, grouped); + } + + /// + /// Get parameter editor by alias. + /// + /// + /// The . + /// + public HttpResponseMessage GetParameterEditorByAlias(string alias) + { + var parameterEditors = Current.ParameterEditors.ToArray(); + + var parameterEditor = parameterEditors.FirstOrDefault(x => x.Alias.InvariantEquals(alias)); + + return this.Request.CreateResponse(HttpStatusCode.OK, parameterEditor); + } + /// /// Returns a error response and optionally logs it /// @@ -280,7 +342,6 @@ namespace Umbraco.Web.Editors /// Finds partial view files in app plugin folders. /// /// - /// The . /// private IEnumerable FindPartialViewFilesInPluginFolders() { @@ -342,5 +403,29 @@ namespace Umbraco.Web.Editors return files; } + + /// + /// Used to map an instance to a + /// + /// + /// + private MacroDisplay MapToDisplay(IMacro macro) + { + var display = Mapper.Map(macro); + + var parameters = macro.Properties.Values + .OrderBy(x => x.SortOrder) + .Select(x => new MacroParameterDisplay() + { + Editor = x.EditorAlias, + Key = x.Alias, + Label = x.Name, + Id = x.Id + }); + + display.Parameters = parameters; + + return display; + } } } diff --git a/src/Umbraco.Web/Editors/MediaController.cs b/src/Umbraco.Web/Editors/MediaController.cs index a673f06e1d..c2561e334e 100644 --- a/src/Umbraco.Web/Editors/MediaController.cs +++ b/src/Umbraco.Web/Editors/MediaController.cs @@ -36,6 +36,7 @@ using Umbraco.Core.PropertyEditors; using Umbraco.Web.ContentApps; using Umbraco.Web.Editors.Binders; using Umbraco.Web.Editors.Filters; +using Umbraco.Core.Models.Entities; namespace Umbraco.Web.Editors { @@ -815,7 +816,7 @@ namespace Umbraco.Web.Editors } else { - throw new EntityNotFoundException(parentId, "The passed id doesn't exist"); + throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.NotFound, "The passed id doesn't exist")); } } else @@ -943,5 +944,31 @@ namespace Umbraco.Web.Editors return hasPathAccess; } + + public PagedResult GetPagedReferences(int id, string entityType, int pageNumber = 1, int pageSize = 100) + { + if (pageNumber <= 0 || pageSize <= 0) + { + throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); + } + + var objectType = ObjectTypes.GetUmbracoObjectType(entityType); + var udiType = ObjectTypes.GetUdiType(objectType); + + var relations = Services.RelationService.GetPagedParentEntitiesByChildId(id, pageNumber - 1, pageSize, out var totalRecords, objectType); + + return new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = relations.Cast().Select(rel => new EntityBasic + { + Id = rel.Id, + Key = rel.Key, + Udi = Udi.Create(udiType, rel.Key), + Icon = rel.ContentTypeIcon, + Name = rel.Name, + Alias = rel.ContentTypeAlias + }) + }; + } } } diff --git a/src/Umbraco.Web/Editors/MediaTypeController.cs b/src/Umbraco.Web/Editors/MediaTypeController.cs index c55f07d559..3a4026423a 100644 --- a/src/Umbraco.Web/Editors/MediaTypeController.cs +++ b/src/Umbraco.Web/Editors/MediaTypeController.cs @@ -19,6 +19,7 @@ using Umbraco.Core.Dictionary; using Umbraco.Core.Logging; using Umbraco.Core.Persistence; using Umbraco.Web.Composing; +using IMediaType = Umbraco.Core.Models.IMediaType; namespace Umbraco.Web.Editors { @@ -109,7 +110,7 @@ namespace Umbraco.Web.Editors [HttpPost] public HttpResponseMessage GetAvailableCompositeMediaTypes(GetAvailableCompositionsFilter filter) { - var result = PerformGetAvailableCompositeContentTypes(filter.ContentTypeId, UmbracoObjectTypes.MediaType, filter.FilterContentTypes, filter.FilterPropertyTypes) + var result = PerformGetAvailableCompositeContentTypes(filter.ContentTypeId, UmbracoObjectTypes.MediaType, filter.FilterContentTypes, filter.FilterPropertyTypes, filter.IsElement) .Select(x => new { contentType = x.Item1, @@ -135,12 +136,18 @@ namespace Umbraco.Web.Editors } public MediaTypeDisplay GetEmpty(int parentId) { - var ct = new MediaType(parentId) + IMediaType mt; + if (parentId != Constants.System.Root) { - Icon = Constants.Icons.MediaImage - }; + var parent = Services.MediaTypeService.Get(parentId); + mt = parent != null ? new MediaType(parent, string.Empty) : new MediaType(parentId); + } + else + mt = new MediaType(parentId); - var dto = Mapper.Map(ct); + mt.Icon = Constants.Icons.MediaImage; + + var dto = Mapper.Map(mt); return dto; } @@ -233,11 +240,11 @@ namespace Umbraco.Web.Editors } var contentType = Services.MediaTypeService.Get(contentItem.ContentTypeId); - var ids = contentType.AllowedContentTypes.Select(x => x.Id.Value).ToArray(); + var ids = contentType.AllowedContentTypes.OrderBy(c => c.SortOrder).Select(x => x.Id.Value).ToArray(); if (ids.Any() == false) return Enumerable.Empty(); - types = Services.MediaTypeService.GetAll(ids).ToList(); + types = Services.MediaTypeService.GetAll(ids).OrderBy(c => ids.IndexOf(c.Id)).ToList(); } var basics = types.Select(Mapper.Map).ToList(); @@ -248,7 +255,7 @@ namespace Umbraco.Web.Editors basic.Description = TranslateItem(basic.Description); } - return basics.OrderBy(x => x.Name); + return basics.OrderBy(c => contentId == Constants.System.Root ? c.Name : string.Empty); } /// diff --git a/src/Umbraco.Web/Editors/MemberTypeController.cs b/src/Umbraco.Web/Editors/MemberTypeController.cs index f406988ae5..fffead9155 100644 --- a/src/Umbraco.Web/Editors/MemberTypeController.cs +++ b/src/Umbraco.Web/Editors/MemberTypeController.cs @@ -89,7 +89,7 @@ namespace Umbraco.Web.Editors [FromUri]string[] filterContentTypes, [FromUri]string[] filterPropertyTypes) { - var result = PerformGetAvailableCompositeContentTypes(contentTypeId, UmbracoObjectTypes.MemberType, filterContentTypes, filterPropertyTypes) + var result = PerformGetAvailableCompositeContentTypes(contentTypeId, UmbracoObjectTypes.MemberType, filterContentTypes, filterPropertyTypes, false) .Select(x => new { contentType = x.Item1, diff --git a/src/Umbraco.Web/Editors/RelationTypeController.cs b/src/Umbraco.Web/Editors/RelationTypeController.cs index faafbb79f1..f12faf77cc 100644 --- a/src/Umbraco.Web/Editors/RelationTypeController.cs +++ b/src/Umbraco.Web/Editors/RelationTypeController.cs @@ -3,12 +3,14 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Web.Http; +using System.Linq; using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Configuration; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Mvc; @@ -45,14 +47,28 @@ namespace Umbraco.Web.Editors throw new HttpResponseException(HttpStatusCode.NotFound); } - var relations = Services.RelationService.GetByRelationTypeId(relationType.Id); - var display = Mapper.Map(relationType); - display.Relations = Mapper.MapEnumerable(relations); - + return display; } + public PagedResult GetPagedResults(int id, int pageNumber = 1, int pageSize = 100) + { + + if (pageNumber <= 0 || pageSize <= 0) + { + throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero"); + } + + // Ordering do we need to pass through? + var relations = Services.RelationService.GetPagedByRelationTypeId(id, pageNumber -1, pageSize, out long totalRecords); + + return new PagedResult(totalRecords, pageNumber, pageSize) + { + Items = relations.Select(x => Mapper.Map(x)) + }; + } + /// /// Gets a list of object types which can be associated via relations. /// @@ -84,11 +100,7 @@ namespace Umbraco.Web.Editors /// A containing the persisted relation type's ID. public HttpResponseMessage PostCreate(RelationTypeSave relationType) { - var relationTypePersisted = new RelationType(relationType.ChildObjectType, relationType.ParentObjectType, relationType.Name.ToSafeAlias(true)) - { - Name = relationType.Name, - IsBidirectional = relationType.IsBidirectional - }; + var relationTypePersisted = new RelationType(relationType.Name, relationType.Name.ToSafeAlias(true), relationType.IsBidirectional, relationType.ChildObjectType, relationType.ParentObjectType); try { diff --git a/src/Umbraco.Web/Editors/TinyMceController.cs b/src/Umbraco.Web/Editors/TinyMceController.cs new file mode 100644 index 0000000000..c6c1acd048 --- /dev/null +++ b/src/Umbraco.Web/Editors/TinyMceController.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using System.Web.Http; +using Umbraco.Core; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.IO; +using Umbraco.Core.Logging; +using Umbraco.Core.Services; +using Umbraco.Web.Composing; +using Umbraco.Web.Mvc; +using Umbraco.Web.WebApi; +using Umbraco.Web.WebApi.Filters; +using Constants = Umbraco.Core.Constants; + +namespace Umbraco.Web.Editors +{ + [PluginController("UmbracoApi")] + [UmbracoApplicationAuthorize( + Constants.Applications.Content, + Constants.Applications.Media, + Constants.Applications.Members)] + public class TinyMceController : UmbracoAuthorizedApiController + { + private IMediaService _mediaService; + private IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; + + + public TinyMceController(IMediaService mediaService, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider) + { + _mediaService = mediaService; + _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider; + } + + [HttpPost] + public async Task UploadImage() + { + + if (Request.Content.IsMimeMultipartContent() == false) + { + throw new HttpResponseException(HttpStatusCode.UnsupportedMediaType); + } + + // Create an unique folder path to help with concurrent users to avoid filename clash + var imageTempPath = IOHelper.MapPath(SystemDirectories.TempImageUploads + "/" + Guid.NewGuid().ToString()); + + // Temp folderpath (Files come in as bodypart & will need to move/saved into imgTempPath + var folderPath = IOHelper.MapPath(SystemDirectories.TempFileUploads); + + // Ensure image temp path exists + if(Directory.Exists(imageTempPath) == false) + { + Directory.CreateDirectory(imageTempPath); + } + + // File uploaded will be saved as bodypart into TEMP folder + var provider = new MultipartFormDataStreamProvider(folderPath); + var result = await Request.Content.ReadAsMultipartAsync(provider); + + // Must have a file + if (result.FileData.Count == 0) + { + return Request.CreateResponse(HttpStatusCode.NotFound); + } + + // Should only have one file + if (result.FileData.Count > 1) + { + return Request.CreateErrorResponse(HttpStatusCode.BadRequest, "Only one file can be uploaded at a time"); + } + + // Really we should only have one file per request to this endpoint + var file = result.FileData[0]; + var fileName = file.Headers.ContentDisposition.FileName.Trim(new[] { '\"' }).TrimEnd(); + var safeFileName = fileName.ToSafeFileName(); + var ext = safeFileName.Substring(safeFileName.LastIndexOf('.') + 1).ToLower(); + + if (Current.Configs.Settings().Content.IsFileAllowedForUpload(ext) == false || Current.Configs.Settings().Content.ImageFileTypes.Contains(ext) == false) + { + // Throw some error - to say can't upload this IMG type + return Request.CreateErrorResponse(HttpStatusCode.BadRequest, "This is not an image filetype extension that is approved"); + } + + //var mediaItemName = fileName.ToFriendlyName(); + var currentFile = file.LocalFileName; + var newFilePath = imageTempPath + IOHelper.DirSepChar + safeFileName; + var relativeNewFilePath = IOHelper.GetRelativePath(newFilePath); + + try + { + // Move the file from bodypart to a real filename + // This is what we return from this API so RTE updates img src path + // Until we fully persist & save the media item when persisting + // If we find data attribute + File.Move(currentFile, newFilePath); + } + catch (Exception ex) + { + // IOException, PathTooLong, DirectoryNotFound, UnathorizedAccess + Logger.Error(ex, "Error when trying to move {CurrentFilePath} to {NewFilePath}", currentFile, newFilePath); + return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, $"Error when trying to move {currentFile} to {newFilePath}", ex); + } + + return Request.CreateResponse(HttpStatusCode.OK, new { tmpLocation = relativeNewFilePath }); + } + } +} diff --git a/src/Umbraco.Web/Editors/TourController.cs b/src/Umbraco.Web/Editors/TourController.cs index 166e5a894d..8991bcdd6a 100644 --- a/src/Umbraco.Web/Editors/TourController.cs +++ b/src/Umbraco.Web/Editors/TourController.cs @@ -51,29 +51,35 @@ namespace Umbraco.Web.Editors } //collect all tour files in packages - foreach (var plugin in Directory.EnumerateDirectories(IOHelper.MapPath(SystemDirectories.AppPlugins))) + var appPlugins = IOHelper.MapPath(SystemDirectories.AppPlugins); + if (Directory.Exists(appPlugins)) { - var pluginName = Path.GetFileName(plugin.TrimEnd('\\')); - var pluginFilters = _filters.Where(x => x.PluginName != null && x.PluginName.IsMatch(pluginName)).ToList(); - - //If there is any filter applied to match the plugin only (no file or tour alias) then ignore the plugin entirely - var isPluginFiltered = pluginFilters.Any(x => x.TourFileName == null && x.TourAlias == null); - if (isPluginFiltered) continue; - - //combine matched package filters with filters not specific to a package - var combinedFilters = nonPluginFilters.Concat(pluginFilters).ToList(); - - foreach (var backofficeDir in Directory.EnumerateDirectories(plugin, "backoffice")) + foreach (var plugin in Directory.EnumerateDirectories(appPlugins)) { - foreach (var tourDir in Directory.EnumerateDirectories(backofficeDir, "tours")) + var pluginName = Path.GetFileName(plugin.TrimEnd('\\')); + var pluginFilters = _filters.Where(x => x.PluginName != null && x.PluginName.IsMatch(pluginName)) + .ToList(); + + //If there is any filter applied to match the plugin only (no file or tour alias) then ignore the plugin entirely + var isPluginFiltered = pluginFilters.Any(x => x.TourFileName == null && x.TourAlias == null); + if (isPluginFiltered) continue; + + //combine matched package filters with filters not specific to a package + var combinedFilters = nonPluginFilters.Concat(pluginFilters).ToList(); + + foreach (var backofficeDir in Directory.EnumerateDirectories(plugin, "backoffice")) { - foreach (var tourFile in Directory.EnumerateFiles(tourDir, "*.json")) + foreach (var tourDir in Directory.EnumerateDirectories(backofficeDir, "tours")) { - TryParseTourFile(tourFile, result, combinedFilters, aliasOnlyFilters, pluginName); + foreach (var tourFile in Directory.EnumerateFiles(tourDir, "*.json")) + { + TryParseTourFile(tourFile, result, combinedFilters, aliasOnlyFilters, pluginName); + } } } } } + //Get all allowed sections for the current user var allowedSections = user.AllowedSections.ToList(); @@ -104,6 +110,39 @@ namespace Umbraco.Web.Editors return result.Except(toursToBeRemoved).OrderBy(x => x.FileName, StringComparer.InvariantCultureIgnoreCase); } + /// + /// Gets a tours for a specific doctype + /// + /// The documenttype alias + /// A + public IEnumerable GetToursForDoctype(string doctypeAlias) + { + var tourFiles = this.GetTours(); + + var doctypeAliasWithCompositions = new List + { + doctypeAlias + }; + + var contentType = this.Services.ContentTypeService.Get(doctypeAlias); + + if (contentType != null) + { + doctypeAliasWithCompositions.AddRange(contentType.CompositionAliases()); + } + + return tourFiles.SelectMany(x => x.Tours) + .Where(x => + { + if (string.IsNullOrEmpty(x.ContentType)) + { + return false; + } + var contentTypes = x.ContentType.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(ct => ct.Trim()); + return contentTypes.Intersect(doctypeAliasWithCompositions).Any(); + }); + } + private void TryParseTourFile(string tourFile, ICollection result, List filters, diff --git a/src/Umbraco.Web/Editors/UserGroupsController.cs b/src/Umbraco.Web/Editors/UserGroupsController.cs index 1b64722735..b081ca6137 100644 --- a/src/Umbraco.Web/Editors/UserGroupsController.cs +++ b/src/Umbraco.Web/Editors/UserGroupsController.cs @@ -154,8 +154,8 @@ namespace Umbraco.Web.Editors public HttpResponseMessage PostDeleteUserGroups([FromUri] int[] userGroupIds) { var userGroups = Services.UserService.GetAllUserGroups(userGroupIds) - //never delete the admin group or translators group - .Where(x => x.Alias != Constants.Security.AdminGroupAlias && x.Alias != Constants.Security.TranslatorGroupAlias) + //never delete the admin group, sensitive data or translators group + .Where(x => !x.IsSystemUserGroup()) .ToArray(); foreach (var userGroup in userGroups) { diff --git a/src/Umbraco.Web/Editors/UsersController.cs b/src/Umbraco.Web/Editors/UsersController.cs index e133d2e6b0..c27d7c9f48 100644 --- a/src/Umbraco.Web/Editors/UsersController.cs +++ b/src/Umbraco.Web/Editors/UsersController.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Runtime.Serialization; +using System.Security.Cryptography; using System.Threading.Tasks; using System.Web; using System.Web.Http; @@ -25,6 +26,7 @@ using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Security; using Umbraco.Core.Services; using Umbraco.Web.Editors.Filters; +using Umbraco.Web.Models; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; @@ -105,7 +107,7 @@ namespace Umbraco.Web.Editors if (Current.Configs.Settings().Content.DisallowedUploadFiles.Contains(ext) == false) { //generate a path of known data, we don't want this path to be guessable - user.Avatar = "UserAvatars/" + (user.Id + safeFileName).ToSHA1() + "." + ext; + user.Avatar = "UserAvatars/" + (user.Id + safeFileName).GenerateHash() + "." + ext; using (var fs = System.IO.File.OpenRead(file.LocalFileName)) { @@ -543,34 +545,12 @@ namespace Umbraco.Web.Editors hasErrors = true; } - // if the found user has his email for username, we want to keep this synced when changing the email. + // if the found user has their email for username, we want to keep this synced when changing the email. // we have already cross-checked above that the email isn't colliding with anything, so we can safely assign it here. if (Current.Configs.Settings().Security.UsernameIsEmail && found.Username == found.Email && userSave.Username != userSave.Email) { userSave.Username = userSave.Email; - } - - if (userSave.ChangePassword != null) - { - var passwordChanger = new PasswordChanger(Logger, Services.UserService, UmbracoContext.HttpContext); - - //this will change the password and raise appropriate events - var passwordChangeResult = await passwordChanger.ChangePasswordWithIdentityAsync(Security.CurrentUser, found, userSave.ChangePassword, UserManager); - if (passwordChangeResult.Success) - { - //need to re-get the user - found = Services.UserService.GetUserById(intId.Result); - } - else - { - hasErrors = true; - - foreach (var memberName in passwordChangeResult.Result.ChangeError.MemberNames) - { - ModelState.AddModelError(memberName, passwordChangeResult.Result.ChangeError.ErrorMessage); - } - } - } + } if (hasErrors) throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState)); @@ -586,6 +566,51 @@ namespace Umbraco.Web.Editors return display; } + /// + /// + /// + /// + /// + public async Task> PostChangePassword(ChangingPasswordModel changingPasswordModel) + { + changingPasswordModel = changingPasswordModel ?? throw new ArgumentNullException(nameof(changingPasswordModel)); + + if (ModelState.IsValid == false) + { + throw new HttpResponseException(Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState)); + } + + var intId = changingPasswordModel.Id.TryConvertTo(); + if (intId.Success == false) + { + throw new HttpResponseException(HttpStatusCode.NotFound); + } + + var found = Services.UserService.GetUserById(intId.Result); + if (found == null) + { + throw new HttpResponseException(HttpStatusCode.NotFound); + } + + var passwordChanger = new PasswordChanger(Logger, Services.UserService, UmbracoContext.HttpContext); + var passwordChangeResult = await passwordChanger.ChangePasswordWithIdentityAsync(Security.CurrentUser, found, changingPasswordModel, UserManager); + + if (passwordChangeResult.Success) + { + var result = new ModelWithNotifications(passwordChangeResult.Result.ResetPassword); + result.AddSuccessNotification(Services.TextService.Localize("general/success"), Services.TextService.Localize("user/passwordChangedGeneric")); + return result; + } + + foreach (var memberName in passwordChangeResult.Result.ChangeError.MemberNames) + { + ModelState.AddModelError(memberName, passwordChangeResult.Result.ChangeError.ErrorMessage); + } + + throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); + } + + /// /// Disables the users with the given user ids /// diff --git a/src/Umbraco.Web/ExamineExtensions.cs b/src/Umbraco.Web/ExamineExtensions.cs index 9a9fa98d95..421993f8fd 100644 --- a/src/Umbraco.Web/ExamineExtensions.cs +++ b/src/Umbraco.Web/ExamineExtensions.cs @@ -2,32 +2,95 @@ using System.Collections.Generic; using System.Linq; using Examine; -using Umbraco.Core; +using Examine.LuceneEngine.Providers; using Umbraco.Core.Models.PublishedContent; +using Umbraco.Examine; using Umbraco.Web.PublishedCache; namespace Umbraco.Web { /// - /// Extension methods for Examine + /// Extension methods for Examine. /// public static class ExamineExtensions { + /// + /// Creates an containing all content from the . + /// + /// The search results. + /// The cache to fetch the content from. + /// + /// An containing all content. + /// + /// cache + /// + /// Search results are skipped if it can't be fetched from the by its integer id. + /// public static IEnumerable ToPublishedSearchResults(this IEnumerable results, IPublishedCache cache) { - var list = new List(); + if (cache == null) throw new ArgumentNullException(nameof(cache)); - foreach (var result in results.OrderByDescending(x => x.Score)) + var publishedSearchResults = new List(); + + foreach (var result in results) { - if (!int.TryParse(result.Id, out var intId)) continue; //invalid - var content = cache.GetById(intId); - if (content == null) continue; // skip if this doesn't exist in the cache - - list.Add(new PublishedSearchResult(content, result.Score)); - + if (int.TryParse(result.Id, out var contentId) && + cache.GetById(contentId) is IPublishedContent content) + { + publishedSearchResults.Add(new PublishedSearchResult(content, result.Score)); + } } - return list; + return publishedSearchResults; + } + + /// + /// Creates an containing all content, media or members from the . + /// + /// The search results. + /// The snapshot. + /// + /// An containing all content, media or members. + /// + /// snapshot + /// + /// Search results are skipped if it can't be fetched from the respective cache by its integer id. + /// + public static IEnumerable ToPublishedSearchResults(this IEnumerable results, IPublishedSnapshot snapshot) + { + if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); + + var publishedSearchResults = new List(); + + foreach (var result in results) + { + if (int.TryParse(result.Id, out var contentId) && + result.Values.TryGetValue(LuceneIndex.CategoryFieldName, out var indexType)) + { + IPublishedContent content; + switch (indexType) + { + case IndexTypes.Content: + content = snapshot.Content.GetById(contentId); + break; + case IndexTypes.Media: + content = snapshot.Media.GetById(contentId); + break; + case IndexTypes.Member: + content = snapshot.Members.GetById(contentId); + break; + default: + continue; + } + + if (content != null) + { + publishedSearchResults.Add(new PublishedSearchResult(content, result.Score)); + } + } + } + + return publishedSearchResults; } } } diff --git a/src/Umbraco.Web/GridTemplateExtensions.cs b/src/Umbraco.Web/GridTemplateExtensions.cs index 2c6e66c68b..afa929cfbb 100644 --- a/src/Umbraco.Web/GridTemplateExtensions.cs +++ b/src/Umbraco.Web/GridTemplateExtensions.cs @@ -1,13 +1,10 @@ using System; -using System.Web.Mvc.Html; using System.Web.Mvc; -using System.IO; -using Umbraco.Core.Exceptions; +using System.Web.Mvc.Html; using Umbraco.Core.Models.PublishedContent; namespace Umbraco.Web { - public static class GridTemplateExtensions { public static MvcHtmlString GetGridHtml(this HtmlHelper html, IPublishedProperty property, string framework = "bootstrap3") @@ -26,18 +23,20 @@ namespace Umbraco.Web public static MvcHtmlString GetGridHtml(this HtmlHelper html, IPublishedContent contentItem, string propertyAlias) { - if (string.IsNullOrWhiteSpace(propertyAlias)) throw new ArgumentNullOrEmptyException(nameof(propertyAlias)); + if (propertyAlias == null) throw new ArgumentNullException(nameof(propertyAlias)); + if (string.IsNullOrWhiteSpace(propertyAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(propertyAlias)); return html.GetGridHtml(contentItem, propertyAlias, "bootstrap3"); } public static MvcHtmlString GetGridHtml(this HtmlHelper html, IPublishedContent contentItem, string propertyAlias, string framework) { - if (string.IsNullOrWhiteSpace(propertyAlias)) throw new ArgumentNullOrEmptyException(nameof(propertyAlias)); + if (propertyAlias == null) throw new ArgumentNullException(nameof(propertyAlias)); + if (string.IsNullOrWhiteSpace(propertyAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(propertyAlias)); var view = "Grid/" + framework; var prop = contentItem.GetProperty(propertyAlias); - if (prop == null) throw new NullReferenceException("No property type found with alias " + propertyAlias); + if (prop == null) throw new InvalidOperationException("No property type found with alias " + propertyAlias); var model = prop.GetValue(); var asString = model as string; @@ -54,23 +53,28 @@ namespace Umbraco.Web var view = "Grid/" + framework; return html.Partial(view, property.GetValue()); } + public static MvcHtmlString GetGridHtml(this IPublishedContent contentItem, HtmlHelper html) { return GetGridHtml(contentItem, html, "bodyText", "bootstrap3"); } + public static MvcHtmlString GetGridHtml(this IPublishedContent contentItem, HtmlHelper html, string propertyAlias) { - if (string.IsNullOrWhiteSpace(propertyAlias)) throw new ArgumentNullOrEmptyException(nameof(propertyAlias)); + if (propertyAlias == null) throw new ArgumentNullException(nameof(propertyAlias)); + if (string.IsNullOrWhiteSpace(propertyAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(propertyAlias)); return GetGridHtml(contentItem, html, propertyAlias, "bootstrap3"); } + public static MvcHtmlString GetGridHtml(this IPublishedContent contentItem, HtmlHelper html, string propertyAlias, string framework) { - if (string.IsNullOrWhiteSpace(propertyAlias)) throw new ArgumentNullOrEmptyException(nameof(propertyAlias)); + if (propertyAlias == null) throw new ArgumentNullException(nameof(propertyAlias)); + if (string.IsNullOrWhiteSpace(propertyAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(propertyAlias)); var view = "Grid/" + framework; var prop = contentItem.GetProperty(propertyAlias); - if (prop == null) throw new NullReferenceException("No property type found with alias " + propertyAlias); + if (prop == null) throw new InvalidOperationException("No property type found with alias " + propertyAlias); var model = prop.GetValue(); var asString = model as string; @@ -78,12 +82,5 @@ namespace Umbraco.Web return html.Partial(view, model); } - - private class FakeView : IView - { - public void Render(ViewContext viewContext, TextWriter writer) - { - } - } } } diff --git a/src/Umbraco.Web/HealthCheck/Checks/Security/BaseHttpHeaderCheck.cs b/src/Umbraco.Web/HealthCheck/Checks/Security/BaseHttpHeaderCheck.cs index 7d644f8cf1..5e66bc47b1 100644 --- a/src/Umbraco.Web/HealthCheck/Checks/Security/BaseHttpHeaderCheck.cs +++ b/src/Umbraco.Web/HealthCheck/Checks/Security/BaseHttpHeaderCheck.cs @@ -78,7 +78,7 @@ namespace Umbraco.Web.HealthCheck.Checks.Security var response = request.GetResponse(); // Check first for header - success = DoHttpHeadersContainHeader(response); + success = HasMatchingHeader(response.Headers.AllKeys); // If not found, and available, check for meta-tag if (success == false && _metaTagOptionAvailable) @@ -113,9 +113,9 @@ namespace Umbraco.Web.HealthCheck.Checks.Security }; } - private bool DoHttpHeadersContainHeader(WebResponse response) + private bool HasMatchingHeader(IEnumerable headerKeys) { - return response.Headers.AllKeys.Contains(_header); + return headerKeys.Contains(_header, StringComparer.InvariantCultureIgnoreCase); } private bool DoMetaTagsContainKeyForHeader(WebResponse response) @@ -127,7 +127,7 @@ namespace Umbraco.Web.HealthCheck.Checks.Security { var html = reader.ReadToEnd(); var metaTags = ParseMetaTags(html); - return metaTags.ContainsKey(_header); + return HasMatchingHeader(metaTags.Keys); } } } @@ -186,24 +186,22 @@ namespace Umbraco.Web.HealthCheck.Checks.Security } var removeHeaderElement = customHeadersElement.Elements("remove") - .SingleOrDefault(x => x.Attribute("name") != null && - x.Attribute("name")?.Value == _value); + .SingleOrDefault(x => x.Attribute("name")?.Value.Equals(_value, StringComparison.InvariantCultureIgnoreCase) == true); if (removeHeaderElement == null) { - removeHeaderElement = new XElement("remove"); - removeHeaderElement.Add(new XAttribute("name", _header)); - customHeadersElement.Add(removeHeaderElement); + customHeadersElement.Add( + new XElement("remove", + new XAttribute("name", _header))); } var addHeaderElement = customHeadersElement.Elements("add") - .SingleOrDefault(x => x.Attribute("name") != null && - x.Attribute("name").Value == _header); + .SingleOrDefault(x => x.Attribute("name")?.Value.Equals(_header, StringComparison.InvariantCultureIgnoreCase) == true); if (addHeaderElement == null) { - addHeaderElement = new XElement("add"); - addHeaderElement.Add(new XAttribute("name", _header)); - addHeaderElement.Add(new XAttribute("value", _value)); - customHeadersElement.Add(addHeaderElement); + customHeadersElement.Add( + new XElement("add", + new XAttribute("name", _header), + new XAttribute("value", _value))); } doc.Save(configFile); diff --git a/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs b/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs index 26a1d97f67..1d518fa1d3 100644 --- a/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs +++ b/src/Umbraco.Web/HtmlHelperBackOfficeExtensions.cs @@ -13,6 +13,7 @@ using Umbraco.Web.Models; namespace Umbraco.Web { using Core.Configuration; + using Umbraco.Web.JavaScript; /// /// HtmlHelper extensions for the back office @@ -118,6 +119,21 @@ namespace Umbraco.Web sb.AppendLine(JsonConvert.SerializeObject(resetCodeModel)); sb.AppendLine(@"});"); + return html.Raw(sb.ToString()); + } + + public static IHtmlString AngularValueTinyMceAssets(this HtmlHelper html) + { + var ctx = new HttpContextWrapper(HttpContext.Current); + var files = JsInitialization.OptimizeTinyMceScriptFiles(ctx); + + var sb = new StringBuilder(); + + sb.AppendLine(@"app.value(""tinyMceAssets"","); + sb.AppendLine(JsonConvert.SerializeObject(files)); + sb.AppendLine(@");"); + + return html.Raw(sb.ToString()); } } diff --git a/src/Umbraco.Web/HtmlHelperRenderExtensions.cs b/src/Umbraco.Web/HtmlHelperRenderExtensions.cs index 7249500441..ebe5e08f89 100644 --- a/src/Umbraco.Web/HtmlHelperRenderExtensions.cs +++ b/src/Umbraco.Web/HtmlHelperRenderExtensions.cs @@ -8,11 +8,7 @@ using System.Web.Mvc; using System.Web.Mvc.Html; using System.Web.Routing; using Umbraco.Core; -using Umbraco.Core.Composing; -using Umbraco.Core.Configuration; -using Umbraco.Core.Exceptions; using Umbraco.Core.IO; -using Umbraco.Web.Models; using Umbraco.Web.Mvc; using Umbraco.Web.Security; using Current = Umbraco.Web.Composing.Current; @@ -68,7 +64,8 @@ namespace Umbraco.Web var htmlBadge = String.Format(Current.Configs.Settings().Content.PreviewBadge, IOHelper.ResolveUrl(SystemDirectories.Umbraco), - Current.UmbracoContext.HttpContext.Server.UrlEncode(Current.UmbracoContext.HttpContext.Request.Path)); + Current.UmbracoContext.HttpContext.Server.UrlEncode(Current.UmbracoContext.HttpContext.Request.Path), + Current.UmbracoContext.PublishedRequest.PublishedContent.Id); return new MvcHtmlString(htmlBadge); } return new MvcHtmlString(""); @@ -169,8 +166,9 @@ namespace Umbraco.Web /// public static IHtmlString Action(this HtmlHelper htmlHelper, string actionName, Type surfaceType) { + if (actionName == null) throw new ArgumentNullException(nameof(actionName)); + if (string.IsNullOrWhiteSpace(actionName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(actionName)); if (surfaceType == null) throw new ArgumentNullException(nameof(surfaceType)); - if (string.IsNullOrWhiteSpace(actionName)) throw new ArgumentNullOrEmptyException(nameof(actionName)); var routeVals = new RouteValueDictionary(new {area = ""}); @@ -221,7 +219,7 @@ namespace Umbraco.Web { _viewContext = viewContext; _method = method; - _controllerName = controllerName; + _controllerName = controllerName; _encryptedString = UrlHelperRenderExtensions.CreateEncryptedRouteString(controllerName, controllerAction, area, additionalRouteVals); } @@ -230,7 +228,7 @@ namespace Umbraco.Web private readonly FormMethod _method; private bool _disposed; private readonly string _encryptedString; - private readonly string _controllerName; + private readonly string _controllerName; protected override void Dispose(bool disposing) { @@ -243,12 +241,12 @@ namespace Umbraco.Web || _controllerName == "UmbProfile" || _controllerName == "UmbLoginStatus" || _controllerName == "UmbLogin") - { + { _viewContext.Writer.Write(AntiForgery.GetHtml().ToString()); - } + } //write out the hidden surface form routes - _viewContext.Writer.Write(""); + _viewContext.Writer.Write(""); base.Dispose(disposing); } @@ -355,8 +353,10 @@ namespace Umbraco.Web IDictionary htmlAttributes, FormMethod method) { - if (string.IsNullOrWhiteSpace(action)) throw new ArgumentNullOrEmptyException(nameof(action)); - if (string.IsNullOrWhiteSpace(controllerName)) throw new ArgumentNullOrEmptyException(nameof(controllerName)); + if (action == null) throw new ArgumentNullException(nameof(action)); + if (string.IsNullOrWhiteSpace(action)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(action)); + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + if (string.IsNullOrWhiteSpace(controllerName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(controllerName)); return html.BeginUmbracoForm(action, controllerName, "", additionalRouteVals, htmlAttributes, method); } @@ -374,8 +374,10 @@ namespace Umbraco.Web object additionalRouteVals, IDictionary htmlAttributes) { - if (string.IsNullOrWhiteSpace(action)) throw new ArgumentNullOrEmptyException(nameof(action)); - if (string.IsNullOrWhiteSpace(controllerName)) throw new ArgumentNullOrEmptyException(nameof(controllerName)); + if (action == null) throw new ArgumentNullException(nameof(action)); + if (string.IsNullOrWhiteSpace(action)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(action)); + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + if (string.IsNullOrWhiteSpace(controllerName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(controllerName)); return html.BeginUmbracoForm(action, controllerName, "", additionalRouteVals, htmlAttributes); } @@ -575,7 +577,9 @@ namespace Umbraco.Web IDictionary htmlAttributes, FormMethod method) { - if (string.IsNullOrWhiteSpace(action)) throw new ArgumentNullOrEmptyException(nameof(action)); + + if (action == null) throw new ArgumentNullException(nameof(action)); + if (string.IsNullOrWhiteSpace(action)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(action)); if (surfaceType == null) throw new ArgumentNullException(nameof(surfaceType)); var area = ""; @@ -687,8 +691,10 @@ namespace Umbraco.Web IDictionary htmlAttributes, FormMethod method) { - if (string.IsNullOrEmpty(action)) throw new ArgumentNullOrEmptyException(nameof(action)); - if (string.IsNullOrEmpty(controllerName)) throw new ArgumentNullOrEmptyException(nameof(controllerName)); + if (action == null) throw new ArgumentNullException(nameof(action)); + if (string.IsNullOrEmpty(action)) throw new ArgumentException("Value can't be empty.", nameof(action)); + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + if (string.IsNullOrWhiteSpace(controllerName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(controllerName)); var formAction = Current.UmbracoContext.OriginalRequestUrl.PathAndQuery; return html.RenderForm(formAction, method, htmlAttributes, controllerName, action, area, additionalRouteVals); diff --git a/src/Umbraco.Web/HtmlStringUtilities.cs b/src/Umbraco.Web/HtmlStringUtilities.cs index 6cf5092bb0..4606a58a3a 100644 --- a/src/Umbraco.Web/HtmlStringUtilities.cs +++ b/src/Umbraco.Web/HtmlStringUtilities.cs @@ -19,10 +19,26 @@ namespace Umbraco.Web /// Replaces text line breaks with HTML line breaks /// /// The text. - /// The text with text line breaks replaced with HTML line breaks (
    )
    + /// The text with text line breaks replaced with HTML line breaks (<br />). + [Obsolete("This method doesn't HTML encode the text. Use ReplaceLineBreaks instead.")] public HtmlString ReplaceLineBreaksForHtml(string text) { - return new HtmlString(text.Replace("\r\n", @"
    ").Replace("\n", @"
    ").Replace("\r", @"
    ")); + return new HtmlString(text.Replace("\r\n", @"
    ").Replace("\n", @"
    ").Replace("\r", @"
    ")); + } + + /// + /// HTML encodes the text and replaces text line breaks with HTML line breaks. + /// + /// The text. + /// The HTML encoded text with text line breaks replaced with HTML line breaks (<br />). + public IHtmlString ReplaceLineBreaks(string text) + { + var value = HttpUtility.HtmlEncode(text)? + .Replace("\r\n", "
    ") + .Replace("\r", "
    ") + .Replace("\n", "
    "); + + return new HtmlString(value); } public HtmlString StripHtmlTags(string html, params string[] tags) diff --git a/src/Umbraco.Web/HttpUrlHelperExtensions.cs b/src/Umbraco.Web/HttpUrlHelperExtensions.cs index 2eb45465cf..4e5533d327 100644 --- a/src/Umbraco.Web/HttpUrlHelperExtensions.cs +++ b/src/Umbraco.Web/HttpUrlHelperExtensions.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Linq.Expressions; using System.Web.Http.Routing; using Umbraco.Core; -using Umbraco.Core.Exceptions; using Umbraco.Web.Composing; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; @@ -53,7 +52,8 @@ namespace Umbraco.Web /// public static string GetUmbracoApiService(this UrlHelper url, string actionName, Type apiControllerType, object id = null) { - if (string.IsNullOrWhiteSpace(actionName)) throw new ArgumentNullOrEmptyException(nameof(actionName)); + if (actionName == null) throw new ArgumentNullException(nameof(actionName)); + if (string.IsNullOrWhiteSpace(actionName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(actionName)); if (apiControllerType == null) throw new ArgumentNullException(nameof(apiControllerType)); var area = ""; @@ -95,8 +95,10 @@ namespace Umbraco.Web /// public static string GetUmbracoApiService(this UrlHelper url, string actionName, string controllerName, string area, object id = null) { - if (string.IsNullOrWhiteSpace(actionName)) throw new ArgumentNullOrEmptyException(nameof(actionName)); - if (string.IsNullOrWhiteSpace(controllerName)) throw new ArgumentNullOrEmptyException(nameof(controllerName)); + if (actionName == null) throw new ArgumentNullException(nameof(actionName)); + if (string.IsNullOrWhiteSpace(actionName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(actionName)); + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + if (string.IsNullOrWhiteSpace(controllerName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(controllerName)); string routeName; if (area.IsNullOrWhiteSpace()) diff --git a/src/Umbraco.Web/IPublishedContentQuery.cs b/src/Umbraco.Web/IPublishedContentQuery.cs index 8a8d678aba..7066475dc9 100644 --- a/src/Umbraco.Web/IPublishedContentQuery.cs +++ b/src/Umbraco.Web/IPublishedContentQuery.cs @@ -35,52 +35,63 @@ namespace Umbraco.Web /// /// Searches content. /// - /// Term to search. - /// Optional culture. - /// Optional index name. + /// The term to search. + /// The culture (defaults to a culture insensitive search). + /// The name of the index to search (defaults to ). + /// + /// The search results. + /// /// /// - /// When the is not specified or is *, all cultures are searched. + /// When the is not specified or is *, all cultures are searched. /// To search for only invariant documents and fields use null. /// When searching on a specific culture, all culture specific fields are searched for the provided culture and all invariant fields for all documents. /// /// While enumerating results, the ambient culture is changed to be the searched culture. /// - IEnumerable Search(string term, string culture = "*", string indexName = null); + IEnumerable Search(string term, string culture = "*", string indexName = Constants.UmbracoIndexes.ExternalIndexName); /// /// Searches content. /// - /// Term to search. - /// Numbers of items to skip. - /// Numbers of items to return. - /// Total number of matching items. - /// Optional culture. - /// Optional index name. + /// The term to search. + /// The amount of results to skip. + /// The amount of results to take/return. + /// The total amount of records. + /// The culture (defaults to a culture insensitive search). + /// The name of the index to search (defaults to ). + /// + /// The search results. + /// /// /// - /// When the is not specified or is *, all cultures are searched. + /// When the is not specified or is *, all cultures are searched. /// To search for only invariant documents and fields use null. /// When searching on a specific culture, all culture specific fields are searched for the provided culture and all invariant fields for all documents. /// /// While enumerating results, the ambient culture is changed to be the searched culture. /// - IEnumerable Search(string term, int skip, int take, out long totalRecords, string culture = "*", string indexName = null); + IEnumerable Search(string term, int skip, int take, out long totalRecords, string culture = "*", string indexName = Constants.UmbracoIndexes.ExternalIndexName); /// - /// Executes the query and converts the results to PublishedSearchResult. + /// Executes the query and converts the results to . /// - /// - /// While enumerating results, the ambient culture is changed to be the searched culture. - /// + /// The query. + /// + /// The search results. + /// IEnumerable Search(IQueryExecutor query); /// - /// Executes the query and converts the results to PublishedSearchResult. + /// Executes the query and converts the results to . /// - /// - /// While enumerating results, the ambient culture is changed to be the searched culture. - /// + /// The query. + /// The amount of results to skip. + /// The amount of results to take/return. + /// The total amount of records. + /// + /// The search results. + /// IEnumerable Search(IQueryExecutor query, int skip, int take, out long totalRecords); } } diff --git a/src/Umbraco.Web/Install/Controllers/InstallController.cs b/src/Umbraco.Web/Install/Controllers/InstallController.cs index f45e7a0ae0..902ebbcd53 100644 --- a/src/Umbraco.Web/Install/Controllers/InstallController.cs +++ b/src/Umbraco.Web/Install/Controllers/InstallController.cs @@ -6,6 +6,7 @@ using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Migrations.Install; using Umbraco.Web.JavaScript; +using Umbraco.Web.Mvc; using Umbraco.Web.Security; namespace Umbraco.Web.Install.Controllers @@ -35,6 +36,7 @@ namespace Umbraco.Web.Install.Controllers } [HttpGet] + [StatusCodeResult(System.Net.HttpStatusCode.ServiceUnavailable)] public ActionResult Index() { if (_runtime.Level == RuntimeLevel.Run) diff --git a/src/Umbraco.Web/Install/FilePermissionHelper.cs b/src/Umbraco.Web/Install/FilePermissionHelper.cs index ede9008514..af62b4fd9e 100644 --- a/src/Umbraco.Web/Install/FilePermissionHelper.cs +++ b/src/Umbraco.Web/Install/FilePermissionHelper.cs @@ -45,8 +45,8 @@ namespace Umbraco.Web.Install /// /// This will test the directories for write access /// - /// - /// + /// + /// /// /// If this is false, the easiest way to test for write access is to write a temp file, however some folder will cause /// an App Domain restart if a file is written to the folder, so in that case we need to use the ACL APIs which aren't as diff --git a/src/Umbraco.Web/Install/InstallException.cs b/src/Umbraco.Web/Install/InstallException.cs index a9f254e921..3dc297e6b2 100644 --- a/src/Umbraco.Web/Install/InstallException.cs +++ b/src/Umbraco.Web/Install/InstallException.cs @@ -1,44 +1,106 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Runtime.Serialization; namespace Umbraco.Web.Install { /// - /// Used for steps to be able to return a json structure back to the UI + /// Used for steps to be able to return a JSON structure back to the UI. /// - internal class InstallException : Exception + /// + [Serializable] + public class InstallException : Exception { - private readonly string _message; + /// + /// Gets the view. + /// + /// + /// The view. + /// public string View { get; private set; } + + /// + /// Gets the view model. + /// + /// + /// The view model. + /// + /// + /// This object is not included when serializing. + /// public object ViewModel { get; private set; } - public override string Message - { - get { return _message; } - } + /// + /// Initializes a new instance of the class. + /// + public InstallException() + { } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public InstallException(string message) + : this(message, "error", null) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The view model. + public InstallException(string message, object viewModel) + : this(message, "error", viewModel) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The view. + /// The view model. public InstallException(string message, string view, object viewModel) + : base(message) { - _message = message; View = view; ViewModel = viewModel; } - public InstallException(string message, object viewModel) + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public InstallException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected InstallException(SerializationInfo info, StreamingContext context) + : base(info, context) { - _message = message; - View = "error"; - ViewModel = viewModel; + View = info.GetString(nameof(View)); } - public InstallException(string message) + /// + /// When overridden in a derived class, sets the with information about the exception. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + /// info + public override void GetObjectData(SerializationInfo info, StreamingContext context) { - _message = message; - View = "error"; - ViewModel = null; + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + + info.AddValue(nameof(View), View); + + base.GetObjectData(info, context); } } } diff --git a/src/Umbraco.Web/Install/InstallSteps/ConfigureMachineKey.cs b/src/Umbraco.Web/Install/InstallSteps/ConfigureMachineKey.cs index 4d64d30e9d..c273d642ed 100644 --- a/src/Umbraco.Web/Install/InstallSteps/ConfigureMachineKey.cs +++ b/src/Umbraco.Web/Install/InstallSteps/ConfigureMachineKey.cs @@ -39,7 +39,8 @@ namespace Umbraco.Web.Install.InstallSteps var fileName = IOHelper.MapPath($"{SystemDirectories.Root}/web.config"); var xml = XDocument.Load(fileName, LoadOptions.PreserveWhitespace); - var systemWeb = xml.Root.DescendantsAndSelf("system.web").Single(); + // we only want to get the element that is under the root, (there may be more under tags we don't want them) + var systemWeb = xml.Root.Element("system.web"); // Update appSetting if it exists, or else create a new appSetting for the given key and value var machineKey = systemWeb.Descendants("machineKey").FirstOrDefault(); diff --git a/src/Umbraco.Web/JavaScript/JsInitialization.cs b/src/Umbraco.Web/JavaScript/JsInitialization.cs index 638b1bf5b4..17e4f7b094 100644 --- a/src/Umbraco.Web/JavaScript/JsInitialization.cs +++ b/src/Umbraco.Web/JavaScript/JsInitialization.cs @@ -131,6 +131,17 @@ namespace Umbraco.Web.JavaScript return resources.Where(x => x.Type == JTokenType.String).Select(x => x.ToString()); } + internal static IEnumerable GetTinyMceInitialization() + { + var resources = JsonConvert.DeserializeObject(Resources.TinyMceInitialize); + return resources.Where(x => x.Type == JTokenType.String).Select(x => x.ToString()); + } + + internal static IEnumerable OptimizeTinyMceScriptFiles(HttpContextBase httpContext) + { + return OptimizeScriptFiles(httpContext, GetTinyMceInitialization()); + } + /// /// Parses the JsResources.Main and replaces the replacement tokens accordingly. /// diff --git a/src/Umbraco.Web/JavaScript/JsInitialize.js b/src/Umbraco.Web/JavaScript/JsInitialize.js index 1ef6e74dfe..4b0a457b64 100644 --- a/src/Umbraco.Web/JavaScript/JsInitialize.js +++ b/src/Umbraco.Web/JavaScript/JsInitialize.js @@ -13,6 +13,7 @@ 'lib/angular-route/angular-route.js', 'lib/angular-cookies/angular-cookies.js', + 'lib/angular-aria/angular-aria.min.js', 'lib/angular-touch/angular-touch.js', 'lib/angular-sanitize/angular-sanitize.js', 'lib/angular-animate/angular-animate.js', diff --git a/src/Umbraco.Web/JavaScript/Resources.Designer.cs b/src/Umbraco.Web/JavaScript/Resources.Designer.cs index ca571b1186..f11839f6ca 100644 --- a/src/Umbraco.Web/JavaScript/Resources.Designer.cs +++ b/src/Umbraco.Web/JavaScript/Resources.Designer.cs @@ -146,5 +146,17 @@ namespace Umbraco.Web.JavaScript { return ResourceManager.GetString("ServerVariables", resourceCulture); } } + + /// + /// Looks up a localized string similar to [ + /// '../lib/tinymce/tinymce.min.js', + ///] + ///. + /// + internal static string TinyMceInitialize { + get { + return ResourceManager.GetString("TinyMceInitialize", resourceCulture); + } + } } } diff --git a/src/Umbraco.Web/JavaScript/Resources.resx b/src/Umbraco.Web/JavaScript/Resources.resx index 95526f51e7..1adcaeeb4a 100644 --- a/src/Umbraco.Web/JavaScript/Resources.resx +++ b/src/Umbraco.Web/JavaScript/Resources.resx @@ -130,4 +130,7 @@ servervariables.js;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8 - + + TinyMceInitialize.js;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8 + + \ No newline at end of file diff --git a/src/Umbraco.Web/JavaScript/TinyMceInitialize.js b/src/Umbraco.Web/JavaScript/TinyMceInitialize.js new file mode 100644 index 0000000000..6cb9cf6277 --- /dev/null +++ b/src/Umbraco.Web/JavaScript/TinyMceInitialize.js @@ -0,0 +1,17 @@ +[ + 'lib/tinymce/tinymce.min.js', + + 'lib/tinymce/plugins/paste/plugin.min.js', + 'lib/tinymce/plugins/anchor/plugin.min.js', + 'lib/tinymce/plugins/charmap/plugin.min.js', + 'lib/tinymce/plugins/table/plugin.min.js', + 'lib/tinymce/plugins/lists/plugin.min.js', + 'lib/tinymce/plugins/advlist/plugin.min.js', + 'lib/tinymce/plugins/hr/plugin.min.js', + 'lib/tinymce/plugins/autolink/plugin.min.js', + 'lib/tinymce/plugins/directionality/plugin.min.js', + 'lib/tinymce/plugins/tabfocus/plugin.min.js', + 'lib/tinymce/plugins/searchreplace/plugin.min.js', + 'lib/tinymce/plugins/fullscreen/plugin.min.js', + 'lib/tinymce/plugins/noneditable/plugin.min.js' +] diff --git a/src/Umbraco.Web/Macros/MacroRenderer.cs b/src/Umbraco.Web/Macros/MacroRenderer.cs index 61b2f0bda3..8d7d3bc013 100755 --- a/src/Umbraco.Web/Macros/MacroRenderer.cs +++ b/src/Umbraco.Web/Macros/MacroRenderer.cs @@ -186,7 +186,7 @@ namespace Umbraco.Web.Macros foreach (var prop in model.Properties) { var key = prop.Key.ToLowerInvariant(); - prop.Value = macroParams.ContainsKey(key) + prop.Value = macroParams != null && macroParams.ContainsKey(key) ? macroParams[key]?.ToString() ?? string.Empty : string.Empty; } @@ -197,7 +197,8 @@ namespace Umbraco.Web.Macros public MacroContent Render(string macroAlias, IPublishedContent content, IDictionary macroParams) { - var m = _macroService.GetByAlias(macroAlias); + var m = _appCaches.RuntimeCache.GetCacheItem(CacheKeys.MacroFromAliasCacheKey + macroAlias, () => _macroService.GetByAlias(macroAlias)); + if (m == null) throw new InvalidOperationException("No macro found by alias " + macroAlias); diff --git a/src/Umbraco.Web/Media/EmbedProviders/Flickr.cs b/src/Umbraco.Web/Media/EmbedProviders/Flickr.cs index 2ce014c639..861e849fe2 100644 --- a/src/Umbraco.Web/Media/EmbedProviders/Flickr.cs +++ b/src/Umbraco.Web/Media/EmbedProviders/Flickr.cs @@ -25,7 +25,7 @@ namespace Umbraco.Web.Media.EmbedProviders var imageHeight = GetXmlProperty(xmlDocument, "/oembed/height"); var imageTitle = GetXmlProperty(xmlDocument, "/oembed/title"); - return string.Format("\"{3}\"", imageUrl, imageWidth, imageHeight, HttpUtility.HtmlEncode(imageTitle)); + return string.Format("\"{3}\"", imageUrl, imageWidth, imageHeight, HttpUtility.HtmlEncode(imageTitle)); } } } diff --git a/src/Umbraco.Web/Media/Exif/ExifExceptions.cs b/src/Umbraco.Web/Media/Exif/ExifExceptions.cs index 040e84ff99..3d2ce61e9e 100644 --- a/src/Umbraco.Web/Media/Exif/ExifExceptions.cs +++ b/src/Umbraco.Web/Media/Exif/ExifExceptions.cs @@ -1,42 +1,47 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Web.Media.Exif { /// - /// The exception that is thrown when the format of the JPEG/Exif file - /// could not be understood. + /// The exception that is thrown when the format of the JPEG/EXIF file could not be understood. /// - internal class NotValidExifFileException : Exception + /// + [Serializable] + public class NotValidExifFileException : Exception { + /// + /// Initializes a new instance of the class. + /// public NotValidExifFileException() - : base("Not a valid JPEG/Exif file.") - { - ; - } + : base("Not a valid JPEG/EXIF file.") + { } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. public NotValidExifFileException(string message) : base(message) - { - ; - } + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public NotValidExifFileException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected NotValidExifFileException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } - /// - /// The exception that is thrown when an invalid enum type is given to an - /// ExifEnumProperty. - /// - internal class UnknownEnumTypeException : Exception - { - public UnknownEnumTypeException() - : base("Unknown enum type.") - { - ; - } - - public UnknownEnumTypeException(string message) - : base(message) - { - ; - } - } } diff --git a/src/Umbraco.Web/Media/Exif/ExifExtendedProperty.cs b/src/Umbraco.Web/Media/Exif/ExifExtendedProperty.cs index 40ba275fe4..63c1ce3365 100644 --- a/src/Umbraco.Web/Media/Exif/ExifExtendedProperty.cs +++ b/src/Umbraco.Web/Media/Exif/ExifExtendedProperty.cs @@ -64,7 +64,7 @@ namespace Umbraco.Web.Media.Exif return new ExifInterOperability(tagid, 3, 1, ExifBitConverter.GetBytes((ushort)((object)mValue), BitConverterEx.SystemByteOrder, BitConverterEx.SystemByteOrder)); } else - throw new UnknownEnumTypeException(); + throw new InvalidOperationException($"An invalid enum type ({basetype.FullName}) was provided for type {type.FullName}"); } } } diff --git a/src/Umbraco.Web/Media/Exif/JPEGExceptions.cs b/src/Umbraco.Web/Media/Exif/JPEGExceptions.cs index 631c3cb1b9..40fd9f3be8 100644 --- a/src/Umbraco.Web/Media/Exif/JPEGExceptions.cs +++ b/src/Umbraco.Web/Media/Exif/JPEGExceptions.cs @@ -1,99 +1,171 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Web.Media.Exif { - /// - /// The exception that is thrown when the format of the image file - /// could not be understood. - /// - internal class NotValidImageFileException : Exception - { - public NotValidImageFileException() - : base("Not a valid image file.") - { - ; - } - - public NotValidImageFileException(string message) - : base(message) - { - ; - } - } /// - /// The exception that is thrown when the format of the JPEG file - /// could not be understood. + /// The exception that is thrown when the format of the JPEG file could not be understood. /// - internal class NotValidJPEGFileException : Exception + /// + [Serializable] + public class NotValidJPEGFileException : Exception { + /// + /// Initializes a new instance of the class. + /// public NotValidJPEGFileException() : base("Not a valid JPEG file.") - { - ; - } + { } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. public NotValidJPEGFileException(string message) : base(message) - { - ; - } + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public NotValidJPEGFileException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected NotValidJPEGFileException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } /// - /// The exception that is thrown when the format of the TIFF file - /// could not be understood. + /// The exception that is thrown when the format of the TIFF file could not be understood. /// - internal class NotValidTIFFileException : Exception + /// + [Serializable] + public class NotValidTIFFileException : Exception { + /// + /// Initializes a new instance of the class. + /// public NotValidTIFFileException() : base("Not a valid TIFF file.") - { - ; - } + { } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. public NotValidTIFFileException(string message) : base(message) - { - ; - } + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public NotValidTIFFileException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected NotValidTIFFileException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } /// - /// The exception that is thrown when the format of the TIFF header - /// could not be understood. + /// The exception that is thrown when the format of the TIFF header could not be understood. /// + /// + [Serializable] internal class NotValidTIFFHeader : Exception { + /// + /// Initializes a new instance of the class. + /// public NotValidTIFFHeader() : base("Not a valid TIFF header.") - { - ; - } + { } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. public NotValidTIFFHeader(string message) : base(message) - { - ; - } + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public NotValidTIFFHeader(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected NotValidTIFFHeader(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } /// /// The exception that is thrown when the length of a section exceeds 64 kB. /// - internal class SectionExceeds64KBException : Exception + /// + [Serializable] + public class SectionExceeds64KBException : Exception { + /// + /// Initializes a new instance of the class. + /// public SectionExceeds64KBException() : base("Section length exceeds 64 kB.") - { - ; - } + { } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. public SectionExceeds64KBException(string message) : base(message) - { - ; - } + { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public SectionExceeds64KBException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected SectionExceeds64KBException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } } diff --git a/src/Umbraco.Web/Media/TypeDetector/SvgDetector.cs b/src/Umbraco.Web/Media/TypeDetector/SvgDetector.cs index 621e2237bc..fbd1b2c74c 100644 --- a/src/Umbraco.Web/Media/TypeDetector/SvgDetector.cs +++ b/src/Umbraco.Web/Media/TypeDetector/SvgDetector.cs @@ -13,7 +13,7 @@ namespace Umbraco.Web.Media.TypeDetector { document = XDocument.Load(fileStream); } - catch (System.Exception ex) + catch (System.Exception) { return false; } diff --git a/src/Umbraco.Web/ModelStateExtensions.cs b/src/Umbraco.Web/ModelStateExtensions.cs index 706ebf2825..00b17781e0 100644 --- a/src/Umbraco.Web/ModelStateExtensions.cs +++ b/src/Umbraco.Web/ModelStateExtensions.cs @@ -73,7 +73,7 @@ namespace Umbraco.Web } /// - /// Returns a list of cultures that have property validation errors errors + /// Returns a list of cultures that have property validation errors /// /// /// diff --git a/src/Umbraco.Web/Models/BackOfficeTour.cs b/src/Umbraco.Web/Models/BackOfficeTour.cs index d5987ec5bc..7396d3d00d 100644 --- a/src/Umbraco.Web/Models/BackOfficeTour.cs +++ b/src/Umbraco.Web/Models/BackOfficeTour.cs @@ -16,20 +16,32 @@ namespace Umbraco.Web.Models [DataMember(Name = "name")] public string Name { get; set; } + [DataMember(Name = "alias")] public string Alias { get; set; } + [DataMember(Name = "group")] public string Group { get; set; } + [DataMember(Name = "groupOrder")] public int GroupOrder { get; set; } + + [DataMember(Name = "hidden")] + public bool Hidden { get; set; } + [DataMember(Name = "allowDisable")] public bool AllowDisable { get; set; } + [DataMember(Name = "requiredSections")] public List RequiredSections { get; set; } + [DataMember(Name = "steps")] public BackOfficeTourStep[] Steps { get; set; } [DataMember(Name = "culture")] public string Culture { get; set; } + + [DataMember(Name = "contentType")] + public string ContentType { get; set; } } } diff --git a/src/Umbraco.Web/Models/ChangingPasswordModel.cs b/src/Umbraco.Web/Models/ChangingPasswordModel.cs index 355b358049..633c42a056 100644 --- a/src/Umbraco.Web/Models/ChangingPasswordModel.cs +++ b/src/Umbraco.Web/Models/ChangingPasswordModel.cs @@ -49,5 +49,11 @@ namespace Umbraco.Web.Models ///
    [DataMember(Name = "generatedPassword")] public string GeneratedPassword { get; set; } + + /// + /// The id of the user - required to allow changing password without the entire UserSave model + /// + [DataMember(Name = "id")] + public int Id { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/ContentPropertyBasic.cs b/src/Umbraco.Web/Models/ContentEditing/ContentPropertyBasic.cs index c5c22484ad..2b70a63035 100644 --- a/src/Umbraco.Web/Models/ContentEditing/ContentPropertyBasic.cs +++ b/src/Umbraco.Web/Models/ContentEditing/ContentPropertyBasic.cs @@ -53,11 +53,21 @@ namespace Umbraco.Web.Models.ContentEditing [ReadOnly(true)] public string Culture { get; set; } + /// + /// The segment of the property + /// + /// + /// The segment value of a property can always be null but can only have a non-null value + /// when the property can be varied by segment. + /// + [DataMember(Name = "segment")] + [ReadOnly(true)] + public string Segment { get; set; } + /// /// Used internally during model mapping /// [IgnoreDataMember] internal IDataEditor PropertyEditor { get; set; } - } } diff --git a/src/Umbraco.Web/Models/ContentEditing/ContentPropertyDto.cs b/src/Umbraco.Web/Models/ContentEditing/ContentPropertyDto.cs index 1f9d0e9b77..83e5e2a9b2 100644 --- a/src/Umbraco.Web/Models/ContentEditing/ContentPropertyDto.cs +++ b/src/Umbraco.Web/Models/ContentEditing/ContentPropertyDto.cs @@ -11,10 +11,17 @@ namespace Umbraco.Web.Models.ContentEditing internal class ContentPropertyDto : ContentPropertyBasic { public IDataType DataType { get; set; } + public string Label { get; set; } + public string Description { get; set; } + public bool IsRequired { get; set; } + + public string IsRequiredMessage { get; set; } + public string ValidationRegExp { get; set; } + public string ValidationRegExpMessage { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/ContentVariantSave.cs b/src/Umbraco.Web/Models/ContentEditing/ContentVariantSave.cs index deadac949a..9a7555ad92 100644 --- a/src/Umbraco.Web/Models/ContentEditing/ContentVariantSave.cs +++ b/src/Umbraco.Web/Models/ContentEditing/ContentVariantSave.cs @@ -29,6 +29,12 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "culture")] public string Culture { get; set; } + /// + /// The segment of this variant, if this is invariant than this is null or empty + /// + [DataMember(Name = "segment")] + public string Segment { get; set; } + /// /// Indicates if the variant should be updated /// diff --git a/src/Umbraco.Web/Models/ContentEditing/DataTypeReferences.cs b/src/Umbraco.Web/Models/ContentEditing/DataTypeReferences.cs new file mode 100644 index 0000000000..d9cd93e7c8 --- /dev/null +++ b/src/Umbraco.Web/Models/ContentEditing/DataTypeReferences.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models.ContentEditing +{ + [DataContract(Name = "dataTypeReferences", Namespace = "")] + public class DataTypeReferences + { + [DataMember(Name = "documentTypes")] + public IEnumerable DocumentTypes { get; set; } = Enumerable.Empty(); + + [DataMember(Name = "mediaTypes")] + public IEnumerable MediaTypes { get; set; } = Enumerable.Empty(); + + [DataMember(Name = "memberTypes")] + public IEnumerable MemberTypes { get; set; } = Enumerable.Empty(); + + [DataContract(Name = "contentType", Namespace = "")] + public class ContentTypeReferences : EntityBasic + { + [DataMember(Name = "properties")] + public object Properties { get; set; } + + [DataContract(Name = "property", Namespace = "")] + public class PropertyTypeReferences + { + [DataMember(Name = "name")] + public string Name { get; set; } + + [DataMember(Name = "alias")] + public string Alias { get; set; } + } + } + } +} diff --git a/src/Umbraco.Web/Models/ContentEditing/GetAvailableCompositionsFilter.cs b/src/Umbraco.Web/Models/ContentEditing/GetAvailableCompositionsFilter.cs index 38442f6da5..b1528db697 100644 --- a/src/Umbraco.Web/Models/ContentEditing/GetAvailableCompositionsFilter.cs +++ b/src/Umbraco.Web/Models/ContentEditing/GetAvailableCompositionsFilter.cs @@ -16,5 +16,10 @@ /// along with any content types that have matching property types that are included in the filtered content types ///
    public string[] FilterContentTypes { get; set; } + + /// + /// Wether the content type is currently marked as an element type + /// + public bool IsElement { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/MediaTypeDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/MediaTypeDisplay.cs index 40227184db..ea0393336c 100644 --- a/src/Umbraco.Web/Models/ContentEditing/MediaTypeDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/MediaTypeDisplay.cs @@ -5,6 +5,7 @@ namespace Umbraco.Web.Models.ContentEditing [DataContract(Name = "contentType", Namespace = "")] public class MediaTypeDisplay : ContentTypeCompositionDisplay { - + [DataMember(Name = "isSystemMediaType")] + public bool IsSystemMediaType { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/MemberDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/MemberDisplay.cs index fd1c1ed5b8..3857731671 100644 --- a/src/Umbraco.Web/Models/ContentEditing/MemberDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/MemberDisplay.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Runtime.Serialization; using Umbraco.Core.Models; +using Umbraco.Core.Models.ContentEditing; using Umbraco.Core.Models.Membership; namespace Umbraco.Web.Models.ContentEditing @@ -15,6 +16,7 @@ namespace Umbraco.Web.Models.ContentEditing public MemberDisplay() { MemberProviderFieldMapping = new Dictionary(); + ContentApps = new List(); } [DataMember(Name = "username")] @@ -34,5 +36,7 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "fieldConfig")] public IDictionary MemberProviderFieldMapping { get; set; } + [DataMember(Name = "apps")] + public IEnumerable ContentApps { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/PropertyTypeValidation.cs b/src/Umbraco.Web/Models/ContentEditing/PropertyTypeValidation.cs index 929c7ce324..a45af12341 100644 --- a/src/Umbraco.Web/Models/ContentEditing/PropertyTypeValidation.cs +++ b/src/Umbraco.Web/Models/ContentEditing/PropertyTypeValidation.cs @@ -11,7 +11,13 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "mandatory")] public bool Mandatory { get; set; } + [DataMember(Name = "mandatoryMessage")] + public string MandatoryMessage { get; set; } + [DataMember(Name = "pattern")] public string Pattern { get; set; } + + [DataMember(Name = "patternMessage")] + public string PatternMessage { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/RelationTypeDisplay.cs b/src/Umbraco.Web/Models/ContentEditing/RelationTypeDisplay.cs index 55e140d6f3..b7f209cd20 100644 --- a/src/Umbraco.Web/Models/ContentEditing/RelationTypeDisplay.cs +++ b/src/Umbraco.Web/Models/ContentEditing/RelationTypeDisplay.cs @@ -24,7 +24,7 @@ namespace Umbraco.Web.Models.ContentEditing ///
    /// Corresponds to the NodeObjectType in the umbracoNode table [DataMember(Name = "parentObjectType", IsRequired = true)] - public Guid ParentObjectType { get; set; } + public Guid? ParentObjectType { get; set; } /// /// Gets or sets the Parent's object type name. @@ -38,7 +38,7 @@ namespace Umbraco.Web.Models.ContentEditing /// /// Corresponds to the NodeObjectType in the umbracoNode table [DataMember(Name = "childObjectType", IsRequired = true)] - public Guid ChildObjectType { get; set; } + public Guid? ChildObjectType { get; set; } /// /// Gets or sets the Child's object type name. @@ -47,13 +47,6 @@ namespace Umbraco.Web.Models.ContentEditing [ReadOnly(true)] public string ChildObjectTypeName { get; set; } - /// - /// Gets or sets the relations associated with this relation type. - /// - [DataMember(Name = "relations")] - [ReadOnly(true)] - public IEnumerable Relations { get; set; } - /// /// This is used to add custom localized messages/strings to the response for the app to use for localized UI purposes. /// diff --git a/src/Umbraco.Web/Models/ContentEditing/RelationTypeSave.cs b/src/Umbraco.Web/Models/ContentEditing/RelationTypeSave.cs index e7e8d6d2ba..434cf1de89 100644 --- a/src/Umbraco.Web/Models/ContentEditing/RelationTypeSave.cs +++ b/src/Umbraco.Web/Models/ContentEditing/RelationTypeSave.cs @@ -16,12 +16,12 @@ namespace Umbraco.Web.Models.ContentEditing /// Gets or sets the parent object type ID. /// [DataMember(Name = "parentObjectType", IsRequired = false)] - public Guid ParentObjectType { get; set; } + public Guid? ParentObjectType { get; set; } /// /// Gets or sets the child object type ID. /// [DataMember(Name = "childObjectType", IsRequired = false)] - public Guid ChildObjectType { get; set; } + public Guid? ChildObjectType { get; set; } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/UserGroupBasic.cs b/src/Umbraco.Web/Models/ContentEditing/UserGroupBasic.cs index 0457711c6c..0446ed6d34 100644 --- a/src/Umbraco.Web/Models/ContentEditing/UserGroupBasic.cs +++ b/src/Umbraco.Web/Models/ContentEditing/UserGroupBasic.cs @@ -33,5 +33,11 @@ namespace Umbraco.Web.Models.ContentEditing ///
    [DataMember(Name = "userCount")] public int UserCount { get; set; } + + /// + /// Is the user group a system group e.g. "Administrators", "Sensitive data" or "Translators" + /// + [DataMember(Name = "isSystemUserGroup")] + public bool IsSystemUserGroup { get; set; } } } diff --git a/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicMapper.cs b/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicMapper.cs index 36c1b360b2..cf1bc3c253 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicMapper.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentPropertyBasicMapper.cs @@ -70,8 +70,13 @@ namespace Umbraco.Web.Models.Mapping dest.Culture = culture; + // Get the segment, which is always allowed to be null even if the propertyType *can* be varied by segment. + // There is therefore no need to perform the null check like with culture above. + var segment = !property.PropertyType.VariesBySegment() ? null : context.GetSegment(); + dest.Segment = segment; + // if no 'IncludeProperties' were specified or this property is set to be included - we will map the value and return. - dest.Value = editor.GetValueEditor().ToEditor(property, DataTypeService, culture); + dest.Value = editor.GetValueEditor().ToEditor(property, DataTypeService, culture, segment); } } } diff --git a/src/Umbraco.Web/Models/Mapping/ContentPropertyDisplayMapper.cs b/src/Umbraco.Web/Models/Mapping/ContentPropertyDisplayMapper.cs index f68c5d8b44..d81f81abe2 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentPropertyDisplayMapper.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentPropertyDisplayMapper.cs @@ -42,7 +42,9 @@ namespace Umbraco.Web.Models.Mapping //add the validation information dest.Validation.Mandatory = originalProp.PropertyType.Mandatory; + dest.Validation.MandatoryMessage = originalProp.PropertyType.MandatoryMessage; dest.Validation.Pattern = originalProp.PropertyType.ValidationRegExp; + dest.Validation.PatternMessage = originalProp.PropertyType.ValidationRegExpMessage; if (dest.PropertyEditor == null) { diff --git a/src/Umbraco.Web/Models/Mapping/ContentPropertyDtoMapper.cs b/src/Umbraco.Web/Models/Mapping/ContentPropertyDtoMapper.cs index 72107c6201..fd57e4dd2e 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentPropertyDtoMapper.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentPropertyDtoMapper.cs @@ -21,7 +21,9 @@ namespace Umbraco.Web.Models.Mapping base.Map(property, dest, context); dest.IsRequired = property.PropertyType.Mandatory; + dest.IsRequiredMessage = property.PropertyType.MandatoryMessage; dest.ValidationRegExp = property.PropertyType.ValidationRegExp; + dest.ValidationRegExpMessage = property.PropertyType.ValidationRegExpMessage; dest.Description = property.PropertyType.Description; dest.Label = property.PropertyType.Name; dest.DataType = DataTypeService.GetDataType(property.PropertyType.DataTypeId); diff --git a/src/Umbraco.Web/Models/Mapping/ContentTypeMapDefinition.cs b/src/Umbraco.Web/Models/Mapping/ContentTypeMapDefinition.cs index fc029eabe4..cdd9ad74eb 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentTypeMapDefinition.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentTypeMapDefinition.cs @@ -8,6 +8,7 @@ using Umbraco.Core.Models; using Umbraco.Core.PropertyEditors; using Umbraco.Web.Models.ContentEditing; using Umbraco.Core.Services; +using Umbraco.Core.Exceptions; namespace Umbraco.Web.Models.Mapping { @@ -144,6 +145,7 @@ namespace Umbraco.Web.Models.Mapping //default listview target.ListViewEditorName = Constants.Conventions.DataTypes.ListViewPrefix + "Media"; + target.IsSystemMediaType = source.IsSystemMediaType(); if (string.IsNullOrEmpty(source.Name)) return; @@ -214,16 +216,18 @@ namespace Umbraco.Web.Models.Mapping } // Umbraco.Code.MapAll -CreateDate -DeleteDate -UpdateDate - // Umbraco.Code.MapAll -SupportsPublishing -Key -PropertyEditorAlias -ValueStorageType + // Umbraco.Code.MapAll -SupportsPublishing -Key -PropertyEditorAlias -ValueStorageType -Variations private static void Map(PropertyTypeBasic source, PropertyType target, MapperContext context) { target.Name = source.Label; target.DataTypeId = source.DataTypeId; target.DataTypeKey = source.DataTypeKey; target.Mandatory = source.Validation.Mandatory; + target.MandatoryMessage = source.Validation.MandatoryMessage; target.ValidationRegExp = source.Validation.Pattern; - target.Variations = source.AllowCultureVariant ? ContentVariation.Culture : ContentVariation.Nothing; - + target.ValidationRegExpMessage = source.Validation.PatternMessage; + target.SetVariesBy(ContentVariation.Culture, source.AllowCultureVariant); + if (source.Id > 0) target.Id = source.Id; @@ -364,7 +368,7 @@ namespace Umbraco.Web.Models.Mapping target.Validation = source.Validation; } - // Umbraco.Code.MapAll -CreatorId -Level -SortOrder + // Umbraco.Code.MapAll -CreatorId -Level -SortOrder -Variations // Umbraco.Code.MapAll -CreateDate -UpdateDate -DeleteDate // Umbraco.Code.MapAll -ContentTypeComposition (done by AfterMapSaveToType) private static void MapSaveToTypeBase(TSource source, IContentTypeComposition target, MapperContext context) @@ -394,9 +398,7 @@ namespace Umbraco.Web.Models.Mapping if (!(target is IMemberType)) { - target.Variations = ContentVariation.Nothing; - if (source.AllowCultureVariant) - target.Variations |= ContentVariation.Culture; + target.SetVariesBy(ContentVariation.Culture, source.AllowCultureVariant); } // handle property groups and property types @@ -487,7 +489,7 @@ namespace Umbraco.Web.Models.Mapping target.Udi = MapContentTypeUdi(source); target.UpdateDate = source.UpdateDate; - target.AllowedContentTypes = source.AllowedContentTypes.Select(x => x.Id.Value); + target.AllowedContentTypes = source.AllowedContentTypes.OrderBy(c => c.SortOrder).Select(x => x.Id.Value); target.CompositeContentTypes = source.ContentTypeComposition.Select(x => x.Alias); target.LockedCompositeContentTypes = MapLockedCompositions(source); } @@ -577,7 +579,7 @@ namespace Umbraco.Web.Models.Mapping udiType = Constants.UdiEntityType.DocumentType; break; default: - throw new Exception("panic"); + throw new PanicException($"Source is of type {source.GetType()} which isn't supported here"); } return Udi.Create(udiType, source.Key); diff --git a/src/Umbraco.Web/Models/Mapping/ContentVariantMapper.cs b/src/Umbraco.Web/Models/Mapping/ContentVariantMapper.cs index c279ae2c70..5d076812f3 100644 --- a/src/Umbraco.Web/Models/Mapping/ContentVariantMapper.cs +++ b/src/Umbraco.Web/Models/Mapping/ContentVariantMapper.cs @@ -21,52 +21,117 @@ namespace Umbraco.Web.Models.Mapping public IEnumerable Map(IContent source, MapperContext context) { - var result = new List(); - if (!source.ContentType.VariesByCulture()) + var variesByCulture = source.ContentType.VariesByCulture(); + var variesBySegment = source.ContentType.VariesBySegment(); + + IList variants = new List(); + + if (!variesByCulture && !variesBySegment) { - //this is invariant so just map the IContent instance to ContentVariationDisplay - result.Add(context.Map(source)); + // this is invariant so just map the IContent instance to ContentVariationDisplay + var variantDisplay = context.Map(source); + variants.Add(variantDisplay); + } + else if (variesByCulture && !variesBySegment) + { + var languages = GetLanguages(context); + variants = languages + .Select(language => CreateVariantDisplay(context, source, language, null)) + .ToList(); + } + else if (variesBySegment && !variesByCulture) + { + // Segment only + var segments = GetSegments(source); + variants = segments + .Select(segment => CreateVariantDisplay(context, source, null, segment)) + .ToList(); } else { - var allLanguages = _localizationService.GetAllLanguages().OrderBy(x => x.Id).ToList(); - if (allLanguages.Count == 0) return Enumerable.Empty(); //this should never happen + // Culture and segment + var languages = GetLanguages(context).ToList(); + var segments = GetSegments(source).ToList(); - var langs = context.MapEnumerable(allLanguages).ToList(); - - //create a variant for each language, then we'll populate the values - var variants = langs.Select(x => + if (languages.Count == 0 || segments.Count == 0) { - //We need to set the culture in the mapping context since this is needed to ensure that the correct property values - //are resolved during the mapping - context.SetCulture(x.IsoCode); - return context.Map(source); - }).ToList(); - - for (int i = 0; i < langs.Count; i++) - { - var x = langs[i]; - var variant = variants[i]; - - variant.Language = x; - variant.Name = source.GetCultureName(x.IsoCode); + // This should not happen + throw new InvalidOperationException("No languages or segments available"); } - //Put the default language first in the list & then sort rest by a-z - var defaultLang = variants.SingleOrDefault(x => x.Language.IsDefault); + variants = languages + .SelectMany(language => segments + .Select(segment => CreateVariantDisplay(context, source, language, segment))) + .ToList(); + } - //Remove the default language from the list for now - variants.Remove(defaultLang); - - //Sort the remaining languages a-z - variants = variants.OrderBy(x => x.Language.Name).ToList(); - - //Insert the default language as the first item - variants.Insert(0, defaultLang); + return SortVariants(variants); + } + private IList SortVariants(IList variants) + { + if (variants == null || variants.Count <= 1) + { return variants; } - return result; + + // Default variant first, then order by language, segment. + return variants + .OrderBy(v => IsDefaultLanguage(v) ? 0 : 1) + .ThenBy(v => IsDefaultSegment(v) ? 0 : 1) + .ThenBy(v => v?.Language?.Name) + .ThenBy(v => v.Segment) + .ToList(); + } + + private static bool IsDefaultSegment(ContentVariantDisplay variant) + { + return variant.Segment == null; + } + + private static bool IsDefaultLanguage(ContentVariantDisplay variant) + { + return variant.Language == null || variant.Language.IsDefault; + } + + private IEnumerable GetLanguages(MapperContext context) + { + var allLanguages = _localizationService.GetAllLanguages().OrderBy(x => x.Id).ToList(); + if (allLanguages.Count == 0) + { + // This should never happen + return Enumerable.Empty(); + } + else + { + return context.MapEnumerable(allLanguages).ToList(); + } + } + + /// + /// Returns all segments assigned to the content + /// + /// + /// + /// Returns all segments assigned to the content including 'null' values + /// + private IEnumerable GetSegments(IContent content) + { + return content.Properties.SelectMany(p => p.Values.Select(v => v.Segment)).Distinct(); + } + + private ContentVariantDisplay CreateVariantDisplay(MapperContext context, IContent content, Language language, string segment) + { + context.SetCulture(language?.IsoCode); + context.SetSegment(segment); + + var variantDisplay = context.Map(content); + + variantDisplay.Segment = segment; + variantDisplay.Language = language; + variantDisplay.Name = content.GetCultureName(language?.IsoCode); + + return variantDisplay; } } } diff --git a/src/Umbraco.Web/Models/Mapping/EntityMapDefinition.cs b/src/Umbraco.Web/Models/Mapping/EntityMapDefinition.cs index 38ec557fdb..34b8f664f3 100644 --- a/src/Umbraco.Web/Models/Mapping/EntityMapDefinition.cs +++ b/src/Umbraco.Web/Models/Mapping/EntityMapDefinition.cs @@ -42,14 +42,20 @@ namespace Umbraco.Web.Models.Mapping target.Trashed = source.Trashed; target.Udi = Udi.Create(ObjectTypes.GetUdiType(source.NodeObjectType), source.Key); - if (source.NodeObjectType == Constants.ObjectTypes.Member && target.Icon.IsNullOrWhiteSpace()) - target.Icon = Constants.Icons.Member; - if (source is IContentEntitySlim contentSlim) + { source.AdditionalData["ContentTypeAlias"] = contentSlim.ContentTypeAlias; + } + + if (source is IDocumentEntitySlim documentSlim) + { + source.AdditionalData["IsPublished"] = documentSlim.Published; + } if (source is IMediaEntitySlim mediaSlim) + { source.AdditionalData["MediaPath"] = mediaSlim.MediaPath; + } // NOTE: we're mapping the objects in AdditionalData by object reference here. // it works fine for now, but it's something to keep in mind in the future @@ -171,6 +177,14 @@ namespace Umbraco.Web.Models.Mapping target.Name = source.Values.ContainsKey("nodeName") ? source.Values["nodeName"] : "[no name]"; + if (source.Values.TryGetValue(UmbracoExamineIndex.UmbracoFileFieldName, out var umbracoFile)) + { + if (umbracoFile != null) + { + target.Name = $"{target.Name} ({umbracoFile})"; + } + } + if (source.Values.ContainsKey(UmbracoExamineIndex.NodeKeyFieldName)) { if (Guid.TryParse(source.Values[UmbracoExamineIndex.NodeKeyFieldName], out var key)) @@ -217,7 +231,18 @@ namespace Umbraco.Web.Models.Mapping } private static string MapContentTypeIcon(IEntitySlim entity) - => entity is ContentEntitySlim contentEntity ? contentEntity.ContentTypeIcon : null; + { + switch (entity) + { + case IMemberEntitySlim memberEntity: + return memberEntity.ContentTypeIcon.IfNullOrWhiteSpace(Constants.Icons.Member); + case IContentEntitySlim contentEntity: + // NOTE: this case covers both content and media entities + return contentEntity.ContentTypeIcon; + } + + return null; + } private static string MapName(IEntitySlim source, MapperContext context) { diff --git a/src/Umbraco.Web/Models/Mapping/MacroMapDefinition.cs b/src/Umbraco.Web/Models/Mapping/MacroMapDefinition.cs index e5bca22287..e654fc16a1 100644 --- a/src/Umbraco.Web/Models/Mapping/MacroMapDefinition.cs +++ b/src/Umbraco.Web/Models/Mapping/MacroMapDefinition.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Umbraco.Core; using Umbraco.Core.Logging; @@ -23,6 +24,7 @@ namespace Umbraco.Web.Models.Mapping public void DefineMaps(UmbracoMapper mapper) { mapper.Define((source, context) => new EntityBasic(), Map); + mapper.Define((source, context) => new MacroDisplay(), Map); mapper.Define>((source, context) => context.MapEnumerable(source.Properties.Values)); mapper.Define((source, context) => new MacroParameter(), Map); } @@ -40,6 +42,23 @@ namespace Umbraco.Web.Models.Mapping target.Udi = Udi.Create(Constants.UdiEntityType.Macro, source.Key); } + private void Map(IMacro source, MacroDisplay target, MapperContext context) + { + target.Alias = source.Alias; + target.Icon = Constants.Icons.Macro; + target.Id = source.Id; + target.Key = source.Key; + target.Name = source.Name; + target.ParentId = -1; + target.Path = "-1," + source.Id; + target.Udi = Udi.Create(Constants.UdiEntityType.Macro, source.Key); + target.CacheByPage = source.CacheByPage; + target.CacheByUser = source.CacheByMember; + target.CachePeriod = source.CacheDuration; + target.UseInEditor = source.UseInEditor; + target.RenderInEditor = !source.DontRender; + target.View = source.MacroSource; + } // Umbraco.Code.MapAll -Value private void Map(IMacroProperty source, MacroParameter target, MapperContext context) { diff --git a/src/Umbraco.Web/Models/Mapping/MapperContextExtensions.cs b/src/Umbraco.Web/Models/Mapping/MapperContextExtensions.cs index 1538f1a987..20a387c679 100644 --- a/src/Umbraco.Web/Models/Mapping/MapperContextExtensions.cs +++ b/src/Umbraco.Web/Models/Mapping/MapperContextExtensions.cs @@ -8,6 +8,7 @@ namespace Umbraco.Web.Models.Mapping internal static class MapperContextExtensions { private const string CultureKey = "Map.Culture"; + private const string SegmentKey = "Map.Segment"; private const string IncludedPropertiesKey = "Map.IncludedProperties"; /// @@ -18,6 +19,14 @@ namespace Umbraco.Web.Models.Mapping return context.HasItems && context.Items.TryGetValue(CultureKey, out var obj) && obj is string s ? s : null; } + /// + /// Gets the context segment. + /// + public static string GetSegment(this MapperContext context) + { + return context.HasItems && context.Items.TryGetValue(SegmentKey, out var obj) && obj is string s ? s : null; + } + /// /// Sets a context culture. /// @@ -26,6 +35,14 @@ namespace Umbraco.Web.Models.Mapping context.Items[CultureKey] = culture; } + /// + /// Sets a context segment. + /// + public static void SetSegment(this MapperContext context, string segment) + { + context.Items[SegmentKey] = segment; + } + /// /// Get included properties. /// @@ -42,4 +59,4 @@ namespace Umbraco.Web.Models.Mapping context.Items[IncludedPropertiesKey] = properties; } } -} \ No newline at end of file +} diff --git a/src/Umbraco.Web/Models/Mapping/MemberMapDefinition.cs b/src/Umbraco.Web/Models/Mapping/MemberMapDefinition.cs index 96ca16e18b..851f452244 100644 --- a/src/Umbraco.Web/Models/Mapping/MemberMapDefinition.cs +++ b/src/Umbraco.Web/Models/Mapping/MemberMapDefinition.cs @@ -76,6 +76,7 @@ namespace Umbraco.Web.Models.Mapping // Umbraco.Code.MapAll -Trashed -IsContainer -VariesByCulture private void Map(IMember source, MemberDisplay target, MapperContext context) { + target.ContentApps = _commonMapper.GetContentApps(source); target.ContentTypeId = source.ContentType.Id; target.ContentTypeAlias = source.ContentType.Alias; target.ContentTypeName = source.ContentType.Name; @@ -144,7 +145,7 @@ namespace Umbraco.Web.Models.Mapping target.Id = source.Id; target.Key = source.Key; target.Name = source.Name; - target.Path = "-1" + source.Id; + target.Path = $"-1,{source.Id}"; target.Udi = Udi.Create(Constants.UdiEntityType.MemberGroup, source.Key); } diff --git a/src/Umbraco.Web/Models/Mapping/PropertyTypeGroupMapper.cs b/src/Umbraco.Web/Models/Mapping/PropertyTypeGroupMapper.cs index a184ac92cf..2f5822d1e3 100644 --- a/src/Umbraco.Web/Models/Mapping/PropertyTypeGroupMapper.cs +++ b/src/Umbraco.Web/Models/Mapping/PropertyTypeGroupMapper.cs @@ -223,7 +223,13 @@ namespace Umbraco.Web.Models.Mapping Alias = p.Alias, Description = p.Description, Editor = p.PropertyEditorAlias, - Validation = new PropertyTypeValidation {Mandatory = p.Mandatory, Pattern = p.ValidationRegExp}, + Validation = new PropertyTypeValidation + { + Mandatory = p.Mandatory, + MandatoryMessage = p.MandatoryMessage, + Pattern = p.ValidationRegExp, + PatternMessage = p.ValidationRegExpMessage, + }, Label = p.Name, View = propertyEditor.GetValueEditor().View, Config = config, diff --git a/src/Umbraco.Web/Models/Mapping/RelationMapDefinition.cs b/src/Umbraco.Web/Models/Mapping/RelationMapDefinition.cs index 7787750e54..d26a867858 100644 --- a/src/Umbraco.Web/Models/Mapping/RelationMapDefinition.cs +++ b/src/Umbraco.Web/Models/Mapping/RelationMapDefinition.cs @@ -1,12 +1,23 @@ -using Umbraco.Core; +using System.Linq; +using Umbraco.Core; using Umbraco.Core.Mapping; using Umbraco.Core.Models; +using Umbraco.Core.Services; using Umbraco.Web.Models.ContentEditing; namespace Umbraco.Web.Models.Mapping { internal class RelationMapDefinition : IMapDefinition { + private readonly IEntityService _entityService; + private readonly IRelationService _relationService; + + public RelationMapDefinition(IEntityService entityService, IRelationService relationService) + { + _entityService = entityService; + _relationService = relationService; + } + public void DefineMaps(UmbracoMapper mapper) { mapper.Define((source, context) => new RelationTypeDisplay(), Map); @@ -15,8 +26,8 @@ namespace Umbraco.Web.Models.Mapping } // Umbraco.Code.MapAll -Icon -Trashed -AdditionalData - // Umbraco.Code.MapAll -Relations -ParentId -Notifications - private static void Map(IRelationType source, RelationTypeDisplay target, MapperContext context) + // Umbraco.Code.MapAll -ParentId -Notifications + private void Map(IRelationType source, RelationTypeDisplay target, MapperContext context) { target.ChildObjectType = source.ChildObjectType; target.Id = source.Id; @@ -28,18 +39,32 @@ namespace Umbraco.Web.Models.Mapping target.Udi = Udi.Create(Constants.UdiEntityType.RelationType, source.Key); target.Path = "-1," + source.Id; - // Set the "friendly" names for the parent and child object types - target.ParentObjectTypeName = ObjectTypes.GetUmbracoObjectType(source.ParentObjectType).GetFriendlyName(); - target.ChildObjectTypeName = ObjectTypes.GetUmbracoObjectType(source.ChildObjectType).GetFriendlyName(); + // Set the "friendly" and entity names for the parent and child object types + if (source.ParentObjectType.HasValue) + { + var objType = ObjectTypes.GetUmbracoObjectType(source.ParentObjectType.Value); + target.ParentObjectTypeName = objType.GetFriendlyName(); + } + + if (source.ChildObjectType.HasValue) + { + var objType = ObjectTypes.GetUmbracoObjectType(source.ChildObjectType.Value); + target.ChildObjectTypeName = objType.GetFriendlyName(); + } } // Umbraco.Code.MapAll -ParentName -ChildName - private static void Map(IRelation source, RelationDisplay target, MapperContext context) + private void Map(IRelation source, RelationDisplay target, MapperContext context) { target.ChildId = source.ChildId; target.Comment = source.Comment; target.CreateDate = source.CreateDate; target.ParentId = source.ParentId; + + var entities = _relationService.GetEntitiesFromRelation(source); + + target.ParentName = entities.Item1.Name; + target.ChildName = entities.Item2.Name; } // Umbraco.Code.MapAll -CreateDate -UpdateDate -DeleteDate diff --git a/src/Umbraco.Web/Models/Mapping/UserMapDefinition.cs b/src/Umbraco.Web/Models/Mapping/UserMapDefinition.cs index 3860d5d525..aa158799cb 100644 --- a/src/Umbraco.Web/Models/Mapping/UserMapDefinition.cs +++ b/src/Umbraco.Web/Models/Mapping/UserMapDefinition.cs @@ -138,7 +138,7 @@ namespace Umbraco.Web.Models.Mapping } // Umbraco.Code.MapAll -ContentStartNode -UserCount -MediaStartNode -Key -Sections - // Umbraco.Code.MapAll -Notifications -Udi -Trashed -AdditionalData + // Umbraco.Code.MapAll -Notifications -Udi -Trashed -AdditionalData -IsSystemUserGroup private void Map(IReadOnlyUserGroup source, UserGroupBasic target, MapperContext context) { target.Alias = source.Alias; @@ -147,11 +147,13 @@ namespace Umbraco.Web.Models.Mapping target.Name = source.Name; target.ParentId = -1; target.Path = "-1," + source.Id; + target.IsSystemUserGroup = source.IsSystemUserGroup(); + MapUserGroupBasic(target, source.AllowedSections, source.StartContentId, source.StartMediaId, context); } // Umbraco.Code.MapAll -ContentStartNode -MediaStartNode -Sections -Notifications - // Umbraco.Code.MapAll -Udi -Trashed -AdditionalData + // Umbraco.Code.MapAll -Udi -Trashed -AdditionalData -IsSystemUserGroup private void Map(IUserGroup source, UserGroupBasic target, MapperContext context) { target.Alias = source.Alias; @@ -162,6 +164,8 @@ namespace Umbraco.Web.Models.Mapping target.ParentId = -1; target.Path = "-1," + source.Id; target.UserCount = source.UserCount; + target.IsSystemUserGroup = source.IsSystemUserGroup(); + MapUserGroupBasic(target, source.AllowedSections, source.StartContentId, source.StartMediaId, context); } @@ -210,6 +214,7 @@ namespace Umbraco.Web.Models.Mapping target.ParentId = -1; target.Path = "-1," + source.Id; target.UserCount = source.UserCount; + target.IsSystemUserGroup = source.IsSystemUserGroup(); MapUserGroupBasic(target, source.AllowedSections, source.StartContentId, source.StartMediaId, context); @@ -301,7 +306,7 @@ namespace Umbraco.Web.Models.Mapping target.Avatars = source.GetUserAvatarUrls(_appCaches.RuntimeCache); target.Culture = source.GetUserCulture(_textService, _globalSettings).ToString(); target.Email = source.Email; - target.EmailHash = source.Email.ToLowerInvariant().Trim().ToMd5(); + target.EmailHash = source.Email.ToLowerInvariant().Trim().GenerateHash(); target.Id = source.Id; target.Key = source.Key; target.LastLoginDate = source.LastLoginDate == default ? null : (DateTime?) source.LastLoginDate; @@ -375,8 +380,8 @@ namespace Umbraco.Web.Models.Mapping .ToDictionary(x => x.Key, x => (IEnumerable)x.ToArray()); } - private static string MapContentTypeIcon(EntitySlim entity) - => entity is ContentEntitySlim contentEntity ? contentEntity.ContentTypeIcon : null; + private static string MapContentTypeIcon(IEntitySlim entity) + => entity is IContentEntitySlim contentEntity ? contentEntity.ContentTypeIcon : null; private IEnumerable GetStartNodes(int[] startNodeIds, UmbracoObjectTypes objectType, string localizedKey, MapperContext context) { diff --git a/src/Umbraco.Web/Models/Trees/TreeNode.cs b/src/Umbraco.Web/Models/Trees/TreeNode.cs index 63614b141f..dc383bd18b 100644 --- a/src/Umbraco.Web/Models/Trees/TreeNode.cs +++ b/src/Umbraco.Web/Models/Trees/TreeNode.cs @@ -1,10 +1,9 @@ -using System.Runtime.Serialization; -using Umbraco.Core.IO; +using System; using System.Collections.Generic; +using System.Runtime.Serialization; using Umbraco.Core; using Umbraco.Core.Composing; -using Umbraco.Core.Configuration; -using Umbraco.Core.Exceptions; +using Umbraco.Core.IO; using Umbraco.Web.Models.ContentEditing; namespace Umbraco.Web.Models.Trees @@ -27,7 +26,8 @@ namespace Umbraco.Web.Models.Trees /// internal TreeNode(string nodeId, string parentId, string getChildNodesUrl, string menuUrl) { - if (string.IsNullOrWhiteSpace(nodeId)) throw new ArgumentNullOrEmptyException(nameof(nodeId)); + if (nodeId == null) throw new ArgumentNullException(nameof(nodeId)); + if (string.IsNullOrWhiteSpace(nodeId)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(nodeId)); Id = nodeId; ParentId = parentId; diff --git a/src/Umbraco.Web/Mvc/AreaRegistrationExtensions.cs b/src/Umbraco.Web/Mvc/AreaRegistrationExtensions.cs index 61a659615f..493d9deed2 100644 --- a/src/Umbraco.Web/Mvc/AreaRegistrationExtensions.cs +++ b/src/Umbraco.Web/Mvc/AreaRegistrationExtensions.cs @@ -6,7 +6,6 @@ using System.Web.Routing; using System.Web.SessionState; using Umbraco.Core; using Umbraco.Core.Configuration; -using Umbraco.Core.Exceptions; using Umbraco.Web.WebApi; namespace Umbraco.Web.Mvc @@ -48,7 +47,8 @@ namespace Umbraco.Web.Mvc bool isMvc = true, string areaPathPrefix = "") { - if (string.IsNullOrEmpty(controllerName)) throw new ArgumentNullOrEmptyException(nameof(controllerName)); + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + if (string.IsNullOrEmpty(controllerName)) throw new ArgumentException("Value can't be empty.", nameof(controllerName)); if (controllerSuffixName == null) throw new ArgumentNullException(nameof(controllerSuffixName)); if (controllerType == null) throw new ArgumentNullException(nameof(controllerType)); diff --git a/src/Umbraco.Web/Mvc/ContentModelBinder.cs b/src/Umbraco.Web/Mvc/ContentModelBinder.cs index c6df52e007..052938807a 100644 --- a/src/Umbraco.Web/Mvc/ContentModelBinder.cs +++ b/src/Umbraco.Web/Mvc/ContentModelBinder.cs @@ -135,13 +135,16 @@ namespace Umbraco.Web.Mvc msg.Append(modelType.FullName); msg.Append("."); -// raise event, to give model factories a chance at reporting + // raise event, to give model factories a chance at reporting // the error with more details, and optionally request that // the application restarts. var args = new ModelBindingArgs(sourceType, modelType, msg); ModelBindingException?.Invoke(Instance, args); + // TODO: with all of the tests I've done i don't think restarting the app here is required anymore, + // when I don't have this code enabled and i get a model binding error and just refresh, it fixes itself. + // We'll leave this for now though. if (args.Restart) { msg.Append(" The application is restarting now."); diff --git a/src/Umbraco.Web/Mvc/HttpUmbracoFormRouteStringException.cs b/src/Umbraco.Web/Mvc/HttpUmbracoFormRouteStringException.cs index b08fde081a..db2040665c 100644 --- a/src/Umbraco.Web/Mvc/HttpUmbracoFormRouteStringException.cs +++ b/src/Umbraco.Web/Mvc/HttpUmbracoFormRouteStringException.cs @@ -1,5 +1,4 @@ using System; -using System.Net; using System.Runtime.Serialization; using System.Web; @@ -12,6 +11,13 @@ namespace Umbraco.Web.Mvc [Serializable] public sealed class HttpUmbracoFormRouteStringException : HttpException { + /// + /// Initializes a new instance of the class. + /// + public HttpUmbracoFormRouteStringException() + { } + + /// /// Initializes a new instance of the class. /// /// The that holds the serialized object data about the exception being thrown. @@ -36,6 +42,5 @@ namespace Umbraco.Web.Mvc public HttpUmbracoFormRouteStringException(string message, Exception innerException) : base(message, innerException) { } - } } diff --git a/src/Umbraco.Web/Mvc/ModelBindingException.cs b/src/Umbraco.Web/Mvc/ModelBindingException.cs index d675ae4a65..548b548d4e 100644 --- a/src/Umbraco.Web/Mvc/ModelBindingException.cs +++ b/src/Umbraco.Web/Mvc/ModelBindingException.cs @@ -1,14 +1,45 @@ using System; +using System.Runtime.Serialization; namespace Umbraco.Web.Mvc { + /// + /// The exception that is thrown when an error occurs while binding a source to a model. + /// + /// + [Serializable] public class ModelBindingException : Exception { + /// + /// Initializes a new instance of the class. + /// public ModelBindingException() { } + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. public ModelBindingException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference ( in Visual Basic) if no inner exception is specified. + public ModelBindingException(string message, Exception innerException) + : base(message, innerException) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + protected ModelBindingException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } } diff --git a/src/Umbraco.Web/Mvc/ModelBindingExceptionFilter.cs b/src/Umbraco.Web/Mvc/ModelBindingExceptionFilter.cs index b9161dbea0..5d8fbdac2f 100644 --- a/src/Umbraco.Web/Mvc/ModelBindingExceptionFilter.cs +++ b/src/Umbraco.Web/Mvc/ModelBindingExceptionFilter.cs @@ -1,20 +1,29 @@ using System; +using System.Configuration; using System.Net; using System.Text.RegularExpressions; using System.Web.Mvc; +using Umbraco.Core; +using Umbraco.Web.Composing; namespace Umbraco.Web.Mvc { /// - /// An exception filter checking if we get a or with the same model. in which case it returns a redirect to the same page after 1 sec. + /// An exception filter checking if we get a or with the same model. + /// In which case it returns a redirect to the same page after 1 sec if not in debug mode. /// + /// + /// This is only enabled when running PureLive + /// internal class ModelBindingExceptionFilter : FilterAttribute, IExceptionFilter { private static readonly Regex GetPublishedModelsTypesRegex = new Regex("Umbraco.Web.PublishedModels.(\\w+)", RegexOptions.Compiled); public void OnException(ExceptionContext filterContext) { - if (!filterContext.ExceptionHandled + if (Current.PublishedModelFactory.IsLiveFactory() + && ConfigurationManager.AppSettings["Umbraco.Web.DisableModelBindingExceptionFilter"] != "true" + && !filterContext.ExceptionHandled && ((filterContext.Exception is ModelBindingException || filterContext.Exception is InvalidCastException) && IsMessageAboutTheSameModelType(filterContext.Exception.Message))) { diff --git a/src/Umbraco.Web/Mvc/RenderIndexActionSelectorAttribute.cs b/src/Umbraco.Web/Mvc/RenderIndexActionSelectorAttribute.cs index fa80107181..99aa88b3e2 100644 --- a/src/Umbraco.Web/Mvc/RenderIndexActionSelectorAttribute.cs +++ b/src/Umbraco.Web/Mvc/RenderIndexActionSelectorAttribute.cs @@ -7,7 +7,7 @@ using System.Web.Mvc; namespace Umbraco.Web.Mvc { /// - /// A custom ActionMethodSelector which will ensure that the RenderMvcController.Index(RenderModel model) action will be executed + /// A custom ActionMethodSelector which will ensure that the RenderMvcController.Index(ContentModel model) action will be executed /// if the /// internal class RenderIndexActionSelectorAttribute : ActionMethodSelectorAttribute diff --git a/src/Umbraco.Web/Mvc/RenderRouteHandler.cs b/src/Umbraco.Web/Mvc/RenderRouteHandler.cs index 284c1344b6..c30f83170b 100644 --- a/src/Umbraco.Web/Mvc/RenderRouteHandler.cs +++ b/src/Umbraco.Web/Mvc/RenderRouteHandler.cs @@ -86,7 +86,7 @@ namespace Umbraco.Web.Mvc internal void SetupRouteDataForRequest(ContentModel contentModel, RequestContext requestContext, PublishedRequest frequest) { //put essential data into the data tokens, the 'umbraco' key is required to be there for the view engine - requestContext.RouteData.DataTokens.Add(Core.Constants.Web.UmbracoDataToken, contentModel); //required for the RenderModelBinder and view engine + requestContext.RouteData.DataTokens.Add(Core.Constants.Web.UmbracoDataToken, contentModel); //required for the ContentModelBinder and view engine requestContext.RouteData.DataTokens.Add(Core.Constants.Web.PublishedDocumentRequestDataToken, frequest); //required for RenderMvcController requestContext.RouteData.DataTokens.Add(Core.Constants.Web.UmbracoContextDataToken, UmbracoContext); //required for UmbracoViewPage } @@ -233,7 +233,7 @@ namespace Umbraco.Web.Mvc } /// - /// Returns a RouteDefinition object based on the current renderModel + /// Returns a RouteDefinition object based on the current content request /// /// /// diff --git a/src/Umbraco.Web/Mvc/RenderViewEngine.cs b/src/Umbraco.Web/Mvc/RenderViewEngine.cs index 0fa22616b2..f8a636c87b 100644 --- a/src/Umbraco.Web/Mvc/RenderViewEngine.cs +++ b/src/Umbraco.Web/Mvc/RenderViewEngine.cs @@ -89,7 +89,7 @@ namespace Umbraco.Web.Mvc var umbracoToken = controllerContext.GetDataTokenInViewContextHierarchy(Core.Constants.Web.UmbracoDataToken); // first check if we're rendering a partial view for the back office, or surface controller, etc... - // anything that is not IUmbracoRenderModel as this should only pertain to Umbraco views. + // anything that is not ContentModel as this should only pertain to Umbraco views. if (isPartial && !(umbracoToken is ContentModel)) return true; diff --git a/src/Umbraco.Web/Mvc/RouteDefinition.cs b/src/Umbraco.Web/Mvc/RouteDefinition.cs index 6acafabe1c..89b0467dc3 100644 --- a/src/Umbraco.Web/Mvc/RouteDefinition.cs +++ b/src/Umbraco.Web/Mvc/RouteDefinition.cs @@ -19,7 +19,7 @@ namespace Umbraco.Web.Mvc /// - /// The current RenderModel found for the request + /// Everything related to the current content request including the requested content /// public PublishedRequest PublishedRequest { get; set; } diff --git a/src/Umbraco.Web/Mvc/StatusCodeFilterAttribute.cs b/src/Umbraco.Web/Mvc/StatusCodeFilterAttribute.cs new file mode 100644 index 0000000000..4a78d201ca --- /dev/null +++ b/src/Umbraco.Web/Mvc/StatusCodeFilterAttribute.cs @@ -0,0 +1,28 @@ +using System.Net; +using System.Web.Mvc; +using Umbraco.Core; +using Umbraco.Web.Composing; + +namespace Umbraco.Web.Mvc +{ + /// + /// Forces the response to have a specific http status code + /// + internal class StatusCodeResultAttribute : ActionFilterAttribute + { + private readonly HttpStatusCode _statusCode; + + public StatusCodeResultAttribute(HttpStatusCode statusCode) + { + _statusCode = statusCode; + } + + public override void OnActionExecuted(ActionExecutedContext filterContext) + { + base.OnActionExecuted(filterContext); + + filterContext.HttpContext.Response.StatusCode = (int)_statusCode; + filterContext.HttpContext.Response.TrySkipIisCustomErrors = Current.Configs.Settings().WebRouting.TrySkipIisCustomErrors; + } + } +} diff --git a/src/Umbraco.Web/Mvc/UmbracoViewPageOfTModel.cs b/src/Umbraco.Web/Mvc/UmbracoViewPageOfTModel.cs index 5ac4037fdb..0adb654d45 100644 --- a/src/Umbraco.Web/Mvc/UmbracoViewPageOfTModel.cs +++ b/src/Umbraco.Web/Mvc/UmbracoViewPageOfTModel.cs @@ -215,7 +215,8 @@ namespace Umbraco.Web.Mvc markupToInject = string.Format(Current.Configs.Settings().Content.PreviewBadge, IOHelper.ResolveUrl(SystemDirectories.Umbraco), - Server.UrlEncode(Current.UmbracoContext.HttpContext.Request.Url?.PathAndQuery)); + Server.UrlEncode(Current.UmbracoContext.HttpContext.Request.Url?.PathAndQuery), + Current.UmbracoContext.PublishedRequest.PublishedContent.Id); } else { diff --git a/src/Umbraco.Web/Mvc/UmbracoVirtualNodeByUdiRouteHandler.cs b/src/Umbraco.Web/Mvc/UmbracoVirtualNodeByUdiRouteHandler.cs new file mode 100644 index 0000000000..a1ee6c732a --- /dev/null +++ b/src/Umbraco.Web/Mvc/UmbracoVirtualNodeByUdiRouteHandler.cs @@ -0,0 +1,27 @@ +using System.Web.Routing; +using Umbraco.Core; +using Umbraco.Core.Models.PublishedContent; + +namespace Umbraco.Web.Mvc +{ + public class UmbracoVirtualNodeByUdiRouteHandler : UmbracoVirtualNodeRouteHandler + { + private readonly Udi _realNodeUdi; + + public UmbracoVirtualNodeByUdiRouteHandler(GuidUdi realNodeUdi) + { + _realNodeUdi = realNodeUdi; + } + + protected sealed override IPublishedContent FindContent(RequestContext requestContext, UmbracoContext umbracoContext) + { + var byId = umbracoContext.Content.GetById(_realNodeUdi); + return byId == null ? null : FindContent(requestContext, umbracoContext, byId); + } + + protected virtual IPublishedContent FindContent(RequestContext requestContext, UmbracoContext umbracoContext, IPublishedContent baseContent) + { + return baseContent; + } + } +} diff --git a/src/Umbraco.Web/Mvc/ValidateMvcAngularAntiForgeryTokenAttribute.cs b/src/Umbraco.Web/Mvc/ValidateMvcAngularAntiForgeryTokenAttribute.cs index 756ca7f05c..0803941a70 100644 --- a/src/Umbraco.Web/Mvc/ValidateMvcAngularAntiForgeryTokenAttribute.cs +++ b/src/Umbraco.Web/Mvc/ValidateMvcAngularAntiForgeryTokenAttribute.cs @@ -22,7 +22,7 @@ namespace Umbraco.Web.Mvc var userIdentity = filterContext.HttpContext.User.Identity as ClaimsIdentity; if (userIdentity != null) { - //if there is not CookiePath claim, then exist + //if there is not CookiePath claim, then exit if (userIdentity.HasClaim(x => x.Type == ClaimTypes.CookiePath) == false) { base.OnActionExecuting(filterContext); diff --git a/src/Umbraco.Web/Properties/AssemblyInfo.cs b/src/Umbraco.Web/Properties/AssemblyInfo.cs index 9a1b248284..ce2cbf0282 100644 --- a/src/Umbraco.Web/Properties/AssemblyInfo.cs +++ b/src/Umbraco.Web/Properties/AssemblyInfo.cs @@ -12,7 +12,7 @@ using System.Runtime.InteropServices; // Umbraco Cms [assembly: InternalsVisibleTo("Umbraco.Web.UI")] - +[assembly: InternalsVisibleTo("Umbraco.ModelsBuilder.Embedded")] [assembly: InternalsVisibleTo("Umbraco.Tests")] [assembly: InternalsVisibleTo("Umbraco.Tests.Benchmarks")] @@ -30,7 +30,7 @@ using System.Runtime.InteropServices; [assembly: InternalsVisibleTo("Umbraco.Forms.Web")] // Umbraco Headless -[assembly: InternalsVisibleTo("Umbraco.Headless")] +[assembly: InternalsVisibleTo("Umbraco.Cloud.Headless")] // code analysis // IDE1006 is broken, wants _value syntax for consts, etc - and it's even confusing ppl at MS, kill it diff --git a/src/Umbraco.Web/PropertyEditors/CheckBoxListPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/CheckBoxListPropertyEditor.cs index e0f7184dc7..d81b63f2ad 100644 --- a/src/Umbraco.Web/PropertyEditors/CheckBoxListPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/CheckBoxListPropertyEditor.cs @@ -8,7 +8,12 @@ namespace Umbraco.Web.PropertyEditors /// /// A property editor to allow multiple checkbox selection of pre-defined items. /// - [DataEditor(Constants.PropertyEditors.Aliases.CheckBoxList, "Checkbox list", "checkboxlist", Icon="icon-bulleted-list", Group="lists")] + [DataEditor( + Constants.PropertyEditors.Aliases.CheckBoxList, + "Checkbox list", + "checkboxlist", + Icon = "icon-bulleted-list", + Group = Constants.PropertyEditors.Groups.Lists)] public class CheckBoxListPropertyEditor : DataEditor { private readonly ILocalizedTextService _textService; diff --git a/src/Umbraco.Web/PropertyEditors/ColorPickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/ColorPickerPropertyEditor.cs index 5da9d2888d..be6bde248f 100644 --- a/src/Umbraco.Web/PropertyEditors/ColorPickerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ColorPickerPropertyEditor.cs @@ -4,7 +4,12 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.ColorPicker, "Color Picker", "colorpicker", Icon="icon-colorpicker", Group="Pickers")] + [DataEditor( + Constants.PropertyEditors.Aliases.ColorPicker, + "Color Picker", + "colorpicker", + Icon = "icon-colorpicker", + Group = Constants.PropertyEditors.Groups.Pickers)] public class ColorPickerPropertyEditor : DataEditor { public ColorPickerPropertyEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/ContentPickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/ContentPickerPropertyEditor.cs index 391257ca58..683f1a05c3 100644 --- a/src/Umbraco.Web/PropertyEditors/ContentPickerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ContentPickerPropertyEditor.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using Umbraco.Core; using Umbraco.Core.Logging; +using Umbraco.Core.Models.Editors; using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors @@ -8,7 +9,13 @@ namespace Umbraco.Web.PropertyEditors /// /// Content property editor that stores UDI /// - [DataEditor(Constants.PropertyEditors.Aliases.ContentPicker, EditorType.PropertyValue | EditorType.MacroParameter, "Content Picker", "contentpicker", ValueType = ValueTypes.String, Group = "Pickers")] + [DataEditor( + Constants.PropertyEditors.Aliases.ContentPicker, + EditorType.PropertyValue | EditorType.MacroParameter, + "Content Picker", + "contentpicker", + ValueType = ValueTypes.String, + Group = Constants.PropertyEditors.Groups.Pickers)] public class ContentPickerPropertyEditor : DataEditor { public ContentPickerPropertyEditor(ILogger logger) @@ -19,5 +26,24 @@ namespace Umbraco.Web.PropertyEditors { return new ContentPickerConfigurationEditor(); } + + protected override IDataValueEditor CreateValueEditor() => new ContentPickerPropertyValueEditor(Attribute); + + internal class ContentPickerPropertyValueEditor : DataValueEditor, IDataValueReference + { + public ContentPickerPropertyValueEditor(DataEditorAttribute attribute) : base(attribute) + { + } + + public IEnumerable GetReferences(object value) + { + var asString = value is string str ? str : value?.ToString(); + + if (string.IsNullOrEmpty(asString)) yield break; + + if (Udi.TryParse(asString, out var udi)) + yield return new UmbracoEntityReference(udi); + } + } } } diff --git a/src/Umbraco.Web/PropertyEditors/DateTimePropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/DateTimePropertyEditor.cs index fa3734e8aa..31fbc9e3f1 100644 --- a/src/Umbraco.Web/PropertyEditors/DateTimePropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/DateTimePropertyEditor.cs @@ -7,7 +7,12 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a date and time property editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.DateTime, "Date/Time", "datepicker", ValueType = ValueTypes.DateTime, Icon="icon-time")] + [DataEditor( + Constants.PropertyEditors.Aliases.DateTime, + "Date/Time", + "datepicker", + ValueType = ValueTypes.DateTime, + Icon = "icon-time")] public class DateTimePropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/DecimalPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/DecimalPropertyEditor.cs index 15b33f28b7..880cf8f204 100644 --- a/src/Umbraco.Web/PropertyEditors/DecimalPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/DecimalPropertyEditor.cs @@ -8,7 +8,12 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a decimal property and parameter editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.Decimal, EditorType.PropertyValue | EditorType.MacroParameter, "Decimal", "decimal", ValueType = ValueTypes.Decimal)] + [DataEditor( + Constants.PropertyEditors.Aliases.Decimal, + EditorType.PropertyValue | EditorType.MacroParameter, + "Decimal", + "decimal", + ValueType = ValueTypes.Decimal)] public class DecimalPropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/DropDownFlexiblePropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/DropDownFlexiblePropertyEditor.cs index eac692fcdd..98a19f39ad 100644 --- a/src/Umbraco.Web/PropertyEditors/DropDownFlexiblePropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/DropDownFlexiblePropertyEditor.cs @@ -5,7 +5,12 @@ using Umbraco.Core.Services; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.DropDownListFlexible, "Dropdown", "dropdownFlexible", Group = "lists", Icon = "icon-indent")] + [DataEditor( + Constants.PropertyEditors.Aliases.DropDownListFlexible, + "Dropdown", + "dropdownFlexible", + Group = Constants.PropertyEditors.Groups.Lists, + Icon = "icon-indent")] public class DropDownFlexiblePropertyEditor : DataEditor { private readonly ILocalizedTextService _textService; diff --git a/src/Umbraco.Web/PropertyEditors/EmailAddressPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/EmailAddressPropertyEditor.cs index d55d3327e1..cd3a830c72 100644 --- a/src/Umbraco.Web/PropertyEditors/EmailAddressPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/EmailAddressPropertyEditor.cs @@ -5,7 +5,12 @@ using Umbraco.Core.PropertyEditors.Validators; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.EmailAddress, EditorType.PropertyValue | EditorType.MacroParameter, "Email address", "email", Icon="icon-message")] + [DataEditor( + Constants.PropertyEditors.Aliases.EmailAddress, + EditorType.PropertyValue | EditorType.MacroParameter, + "Email address", + "email", + Icon = "icon-message")] public class EmailAddressPropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/FileUploadPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/FileUploadPropertyEditor.cs index 6d87bfe495..052af18aa1 100644 --- a/src/Umbraco.Web/PropertyEditors/FileUploadPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/FileUploadPropertyEditor.cs @@ -12,8 +12,13 @@ using Umbraco.Web.Media; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.UploadField, "File upload", "fileupload", Icon = "icon-download-alt", Group = "media")] - public class FileUploadPropertyEditor : DataEditor + [DataEditor( + Constants.PropertyEditors.Aliases.UploadField, + "File upload", + "fileupload", + Group = Constants.PropertyEditors.Groups.Media, + Icon = "icon-download-alt")] + public class FileUploadPropertyEditor : DataEditor, IDataEditorWithMediaPath { private readonly IMediaFileSystem _mediaFileSystem; private readonly IContentSection _contentSection; @@ -38,6 +43,8 @@ namespace Umbraco.Web.PropertyEditors return editor; } + public string GetMediaPath(object value) => value?.ToString(); + /// /// Gets a value indicating whether a property is an upload field. /// @@ -47,7 +54,7 @@ namespace Umbraco.Web.PropertyEditors { return property.PropertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.UploadField; } - + /// /// Ensures any files associated are removed /// diff --git a/src/Umbraco.Web/PropertyEditors/GridConfiguration.cs b/src/Umbraco.Web/PropertyEditors/GridConfiguration.cs index b7a76ac960..74ea517fa2 100644 --- a/src/Umbraco.Web/PropertyEditors/GridConfiguration.cs +++ b/src/Umbraco.Web/PropertyEditors/GridConfiguration.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json.Linq; +using Umbraco.Core; using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors @@ -13,12 +14,16 @@ namespace Umbraco.Web.PropertyEditors public JObject Items { get; set; } // TODO: Make these strongly typed, for now this works though - [ConfigurationField("rte", "Rich text editor", "views/propertyeditors/rte/rte.prevalues.html", Description = "Rich text editor configuration")] + [ConfigurationField("rte", "Rich text editor", "views/propertyeditors/rte/rte.prevalues.html", Description = "Rich text editor configuration", HideLabel = true)] public JObject Rte { get; set; } [ConfigurationField(Core.Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes, "Ignore User Start Nodes", "boolean", Description = "Selecting this option allows a user to choose nodes that they normally don't have access to.")] public bool IgnoreUserStartNodes { get; set; } + + [ConfigurationField("mediaParentId", "Image Upload Folder", "MediaFolderPicker", + Description = "Choose the upload location of pasted images")] + public GuidUdi MediaParentId { get; set; } } } diff --git a/src/Umbraco.Web/PropertyEditors/GridConfigurationEditor.cs b/src/Umbraco.Web/PropertyEditors/GridConfigurationEditor.cs index 902cd5b41b..e6b1462df0 100644 --- a/src/Umbraco.Web/PropertyEditors/GridConfigurationEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/GridConfigurationEditor.cs @@ -8,7 +8,7 @@ using Umbraco.Core.PropertyEditors.Validators; namespace Umbraco.Web.PropertyEditors { /// - /// Represents the configuration for the grid value editor. + /// Represents the configuration editor for the grid value editor. /// public class GridConfigurationEditor : ConfigurationEditor { diff --git a/src/Umbraco.Web/PropertyEditors/GridPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/GridPropertyEditor.cs index 99972a27cf..7134fe8703 100644 --- a/src/Umbraco.Web/PropertyEditors/GridPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/GridPropertyEditor.cs @@ -1,23 +1,48 @@ -using System.Linq; -using Umbraco.Core.Logging; -using Examine; -using Lucene.Net.Documents; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; using Umbraco.Core; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Editors; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.Services; +using Umbraco.Web.Templates; namespace Umbraco.Web.PropertyEditors { - using Examine = global::Examine; /// /// Represents a grid property and parameter editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.Grid, "Grid layout", "grid", HideLabel = true, ValueType = ValueTypes.Json, Group="rich content", Icon="icon-layout")] + [DataEditor( + Constants.PropertyEditors.Aliases.Grid, + "Grid layout", + "grid", + HideLabel = true, + ValueType = ValueTypes.Json, + Icon = "icon-layout", + Group = Constants.PropertyEditors.Groups.RichContent)] public class GridPropertyEditor : DataEditor { - public GridPropertyEditor(ILogger logger) + private IUmbracoContextAccessor _umbracoContextAccessor; + private readonly HtmlImageSourceParser _imageSourceParser; + private readonly RichTextEditorPastedImages _pastedImages; + private readonly HtmlLocalLinkParser _localLinkParser; + + public GridPropertyEditor(ILogger logger, + IUmbracoContextAccessor umbracoContextAccessor, + HtmlImageSourceParser imageSourceParser, + RichTextEditorPastedImages pastedImages, + HtmlLocalLinkParser localLinkParser) : base(logger) - { } + { + _umbracoContextAccessor = umbracoContextAccessor; + _imageSourceParser = imageSourceParser; + _pastedImages = pastedImages; + _localLinkParser = localLinkParser; + } public override IPropertyIndexValueFactory PropertyIndexValueFactory => new GridPropertyIndexValueFactory(); @@ -25,15 +50,131 @@ namespace Umbraco.Web.PropertyEditors /// Overridden to ensure that the value is validated /// /// - protected override IDataValueEditor CreateValueEditor() => new GridPropertyValueEditor(Attribute); + protected override IDataValueEditor CreateValueEditor() => new GridPropertyValueEditor(Attribute, _umbracoContextAccessor, _imageSourceParser, _pastedImages, _localLinkParser); protected override IConfigurationEditor CreateConfigurationEditor() => new GridConfigurationEditor(); - internal class GridPropertyValueEditor : DataValueEditor + internal class GridPropertyValueEditor : DataValueEditor, IDataValueReference { - public GridPropertyValueEditor(DataEditorAttribute attribute) + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly HtmlImageSourceParser _imageSourceParser; + private readonly RichTextEditorPastedImages _pastedImages; + private readonly RichTextPropertyEditor.RichTextPropertyValueEditor _richTextPropertyValueEditor; + private readonly MediaPickerPropertyEditor.MediaPickerPropertyValueEditor _mediaPickerPropertyValueEditor; + + public GridPropertyValueEditor(DataEditorAttribute attribute, + IUmbracoContextAccessor umbracoContextAccessor, + HtmlImageSourceParser imageSourceParser, + RichTextEditorPastedImages pastedImages, + HtmlLocalLinkParser localLinkParser) : base(attribute) - { } + { + _umbracoContextAccessor = umbracoContextAccessor; + _imageSourceParser = imageSourceParser; + _pastedImages = pastedImages; + _richTextPropertyValueEditor = new RichTextPropertyEditor.RichTextPropertyValueEditor(attribute, umbracoContextAccessor, imageSourceParser, localLinkParser, pastedImages); + _mediaPickerPropertyValueEditor = new MediaPickerPropertyEditor.MediaPickerPropertyValueEditor(attribute); + } + + /// + /// Format the data for persistence + /// This to ensure if a RTE is used in a Grid cell/control that we parse it for tmp stored images + /// to persist to the media library when we go to persist this to the DB + /// + /// + /// + /// + public override object FromEditor(ContentPropertyData editorValue, object currentValue) + { + if (editorValue.Value == null) + return null; + + // editorValue.Value is a JSON string of the grid + var rawJson = editorValue.Value.ToString(); + if (rawJson.IsNullOrWhiteSpace()) + return null; + + var config = editorValue.DataTypeConfiguration as GridConfiguration; + var mediaParent = config?.MediaParentId; + var mediaParentId = mediaParent == null ? Guid.Empty : mediaParent.Guid; + + var grid = DeserializeGridValue(rawJson, out var rtes, out _); + + var userId = _umbracoContextAccessor.UmbracoContext?.Security?.CurrentUser?.Id ?? Constants.Security.SuperUserId; + + // Process the rte values + foreach (var rte in rtes) + { + // Parse the HTML + var html = rte.Value?.ToString(); + + var parseAndSavedTempImages = _pastedImages.FindAndPersistPastedTempImages(html, mediaParentId, userId); + var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); + + rte.Value = editorValueWithMediaUrlsRemoved; + } + + // Convert back to raw JSON for persisting + return JsonConvert.SerializeObject(grid); + } + + /// + /// Ensures that the rich text editor values are processed within the grid + /// + /// + /// + /// + /// + /// + public override object ToEditor(Property property, IDataTypeService dataTypeService, string culture = null, string segment = null) + { + var val = property.GetValue(culture, segment)?.ToString(); + if (val.IsNullOrWhiteSpace()) return string.Empty; + + var grid = DeserializeGridValue(val, out var rtes, out _); + + //process the rte values + foreach (var rte in rtes.ToList()) + { + var html = rte.Value?.ToString(); + + var propertyValueWithMediaResolved = _imageSourceParser.EnsureImageSources(html); + rte.Value = propertyValueWithMediaResolved; + } + + return grid; + } + + private GridValue DeserializeGridValue(string rawJson, out IEnumerable richTextValues, out IEnumerable mediaValues) + { + var grid = JsonConvert.DeserializeObject(rawJson); + + // Find all controls that use the RTE editor + var controls = grid.Sections.SelectMany(x => x.Rows.SelectMany(r => r.Areas).SelectMany(a => a.Controls)).ToArray(); + richTextValues = controls.Where(x => x.Editor.Alias.ToLowerInvariant() == "rte"); + mediaValues = controls.Where(x => x.Editor.Alias.ToLowerInvariant() == "media"); + + return grid; + } + + /// + /// Resolve references from values + /// + /// + /// + public IEnumerable GetReferences(object value) + { + var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); + DeserializeGridValue(rawJson, out var richTextEditorValues, out var mediaValues); + + foreach (var umbracoEntityReference in richTextEditorValues.SelectMany(x => + _richTextPropertyValueEditor.GetReferences(x.Value))) + yield return umbracoEntityReference; + + foreach (var umbracoEntityReference in mediaValues.SelectMany(x => + _mediaPickerPropertyValueEditor.GetReferences(x.Value["udi"]))) + yield return umbracoEntityReference; + } } } } diff --git a/src/Umbraco.Web/PropertyEditors/GridPropertyIndexValueFactory.cs b/src/Umbraco.Web/PropertyEditors/GridPropertyIndexValueFactory.cs index 085d66f3c6..0cf36122d7 100644 --- a/src/Umbraco.Web/PropertyEditors/GridPropertyIndexValueFactory.cs +++ b/src/Umbraco.Web/PropertyEditors/GridPropertyIndexValueFactory.cs @@ -41,9 +41,7 @@ namespace Umbraco.Web.PropertyEditors { var controlVal = control.Value; - // TODO: If it's not a string, then it's a json formatted value - - // we cannot really index this in a smart way since it could be 'anything' - if (controlVal.Type == JTokenType.String) + if (controlVal?.Type == JTokenType.String) { var str = controlVal.Value(); str = XmlHelper.CouldItBeXml(str) ? str.StripHtml() : str; @@ -53,14 +51,22 @@ namespace Umbraco.Web.PropertyEditors //add the row name as an individual field result.Add(new KeyValuePair>($"{property.Alias}.{rowName}", new[] { str })); } + else if (controlVal is JContainer jc) + { + foreach (var s in jc.Descendants().Where(t => t.Type == JTokenType.String)) + { + sb.Append(s.Value()); + sb.Append(" "); + } + } } } + //First save the raw value to a raw field + result.Add(new KeyValuePair>($"{UmbracoExamineIndex.RawFieldPrefix}{property.Alias}", new[] { rawVal })); + if (sb.Length > 0) { - //First save the raw value to a raw field - result.Add(new KeyValuePair>($"{UmbracoExamineIndex.RawFieldPrefix}{property.Alias}", new[] { rawVal })); - //index the property with the combined/cleaned value result.Add(new KeyValuePair>(property.Alias, new[] { sb.ToString() })); } diff --git a/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyEditor.cs index 86f3412593..b0e5bf30bd 100644 --- a/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ImageCropperPropertyEditor.cs @@ -19,8 +19,15 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents an image cropper property editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.ImageCropper, "Image Cropper", "imagecropper", ValueType = ValueTypes.Json, HideLabel = false, Group="media", Icon="icon-crop")] - public class ImageCropperPropertyEditor : DataEditor + [DataEditor( + Constants.PropertyEditors.Aliases.ImageCropper, + "Image Cropper", + "imagecropper", + ValueType = ValueTypes.Json, + HideLabel = false, + Group = Constants.PropertyEditors.Groups.Media, + Icon = "icon-crop")] + public class ImageCropperPropertyEditor : DataEditor, IDataEditorWithMediaPath { private readonly IMediaFileSystem _mediaFileSystem; private readonly IContentSection _contentSettings; @@ -41,6 +48,8 @@ namespace Umbraco.Web.PropertyEditors _autoFillProperties = new UploadAutoFillProperties(_mediaFileSystem, logger, _contentSettings); } + public string GetMediaPath(object value) => GetFileSrcFromPropertyValue(value, out _, false); + /// /// Creates the corresponding property value editor. /// @@ -56,7 +65,7 @@ namespace Umbraco.Web.PropertyEditors /// /// Gets a value indicating whether a property is an image cropper field. /// - /// The property. + /// The property. /// A value indicating whether a property is an image cropper field, and (optionally) has a non-empty value. private static bool IsCropperField(Property property) { @@ -125,8 +134,9 @@ namespace Umbraco.Web.PropertyEditors /// /// /// The deserialized value + /// Should the path returned be the application relative path /// - private string GetFileSrcFromPropertyValue(object propVal, out JObject deserializedValue) + private string GetFileSrcFromPropertyValue(object propVal, out JObject deserializedValue, bool relative = true) { deserializedValue = null; if (propVal == null || !(propVal is string str)) return null; @@ -134,7 +144,7 @@ namespace Umbraco.Web.PropertyEditors deserializedValue = GetJObject(str, true); if (deserializedValue?["src"] == null) return null; var src = deserializedValue["src"].Value(); - return _mediaFileSystem.GetRelativePath(src); + return relative ? _mediaFileSystem.GetRelativePath(src) : src; } /// diff --git a/src/Umbraco.Web/PropertyEditors/IntegerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/IntegerPropertyEditor.cs index 5d6adc018c..7717ced4a5 100644 --- a/src/Umbraco.Web/PropertyEditors/IntegerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/IntegerPropertyEditor.cs @@ -8,7 +8,12 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents an integer property and parameter editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.Integer, EditorType.PropertyValue | EditorType.MacroParameter, "Numeric", "integer", ValueType = ValueTypes.Integer)] + [DataEditor( + Constants.PropertyEditors.Aliases.Integer, + EditorType.PropertyValue | EditorType.MacroParameter, + "Numeric", + "integer", + ValueType = ValueTypes.Integer)] public class IntegerPropertyEditor : DataEditor { public IntegerPropertyEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/ListViewConfiguration.cs b/src/Umbraco.Web/PropertyEditors/ListViewConfiguration.cs index 4f854a7556..ce41628ec1 100644 --- a/src/Umbraco.Web/PropertyEditors/ListViewConfiguration.cs +++ b/src/Umbraco.Web/PropertyEditors/ListViewConfiguration.cs @@ -60,6 +60,9 @@ namespace Umbraco.Web.PropertyEditors Description = "The bulk actions that are allowed from the list view")] public BulkActionPermissionSettings BulkActionPermissions { get; set; } = new BulkActionPermissionSettings(); // TODO: managing defaults? + [ConfigurationField("icon", "Content app icon", "views/propertyeditors/listview/icon.prevalues.html", Description = "The icon of the listview content app")] + public string Icon { get; set; } + [ConfigurationField("tabName", "Content app name", "textstring", Description = "The name of the listview content app (default if empty: 'Child Items')")] public string TabName { get; set; } @@ -74,6 +77,9 @@ namespace Umbraco.Web.PropertyEditors [JsonProperty("header")] public string Header { get; set; } + [JsonProperty("nameTemplate")] + public string Template { get; set; } + [JsonProperty("isSystem")] public int IsSystem { get; set; } // TODO: bool } diff --git a/src/Umbraco.Web/PropertyEditors/ListViewPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/ListViewPropertyEditor.cs index f170608545..53f9cb94ef 100644 --- a/src/Umbraco.Web/PropertyEditors/ListViewPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ListViewPropertyEditor.cs @@ -8,7 +8,13 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a list-view editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.ListView, "List view", "listview", HideLabel = true, Group = "lists", Icon = Constants.Icons.ListView)] + [DataEditor( + Constants.PropertyEditors.Aliases.ListView, + "List view", + "listview", + HideLabel = true, + Group = Constants.PropertyEditors.Groups.Lists, + Icon = Constants.Icons.ListView)] public class ListViewPropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/MacroContainerConfiguration.cs b/src/Umbraco.Web/PropertyEditors/MacroContainerConfiguration.cs deleted file mode 100644 index 1490c4d2a8..0000000000 --- a/src/Umbraco.Web/PropertyEditors/MacroContainerConfiguration.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Umbraco.Core.PropertyEditors; - -namespace Umbraco.Web.PropertyEditors -{ - /// - /// Represents the configuration for the macro container value editor. - /// - public class MacroContainerConfiguration - { - [ConfigurationField("max", "Max items", "number", Description = "The maximum number of macros that are allowed in the container")] - public int MaxItems { get; set; } - - [ConfigurationField("allowed", "Allowed items", "views/propertyeditors/macrocontainer/macrolist.prevalues.html", Description = "The macro types allowed, if none are selected all macros will be allowed")] - public object AllowedItems { get; set; } - } -} \ No newline at end of file diff --git a/src/Umbraco.Web/PropertyEditors/MacroContainerConfigurationEditor.cs b/src/Umbraco.Web/PropertyEditors/MacroContainerConfigurationEditor.cs deleted file mode 100644 index 1ac4f5c874..0000000000 --- a/src/Umbraco.Web/PropertyEditors/MacroContainerConfigurationEditor.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Umbraco.Core.PropertyEditors; - -namespace Umbraco.Web.PropertyEditors -{ - /// - /// Represents the configuration editor for the macro container value editor. - /// - public class MacroContainerConfigurationEditor : ConfigurationEditor - { } -} \ No newline at end of file diff --git a/src/Umbraco.Web/PropertyEditors/MacroContainerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MacroContainerPropertyEditor.cs deleted file mode 100644 index 99a9f44487..0000000000 --- a/src/Umbraco.Web/PropertyEditors/MacroContainerPropertyEditor.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Umbraco.Core; -using Umbraco.Core.Logging; -using Umbraco.Core.PropertyEditors; - -namespace Umbraco.Web.PropertyEditors -{ - // TODO: MacroContainerPropertyEditor is deprecated, but what's the alternative? - [DataEditor(Constants.PropertyEditors.Aliases.MacroContainer, "(Obsolete) Macro Picker", "macrocontainer", ValueType = ValueTypes.Text, Group = "rich content", Icon = Constants.Icons.Macro, IsDeprecated = true)] - public class MacroContainerPropertyEditor : DataEditor - { - public MacroContainerPropertyEditor(ILogger logger) - : base(logger) - { } - - protected override IConfigurationEditor CreateConfigurationEditor() => new MacroContainerConfigurationEditor(); - } -} diff --git a/src/Umbraco.Web/PropertyEditors/MarkdownPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MarkdownPropertyEditor.cs index 99b68a507b..2d66da5461 100644 --- a/src/Umbraco.Web/PropertyEditors/MarkdownPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MarkdownPropertyEditor.cs @@ -7,7 +7,13 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a markdown editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.MarkdownEditor, "Markdown editor", "markdowneditor", ValueType = ValueTypes.Text, Icon="icon-code", Group="rich content")] + [DataEditor( + Constants.PropertyEditors.Aliases.MarkdownEditor, + "Markdown editor", + "markdowneditor", + ValueType = ValueTypes.Text, + Group = Constants.PropertyEditors.Groups.RichContent, + Icon = "icon-code")] public class MarkdownPropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/MediaPickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MediaPickerPropertyEditor.cs index 52e616ffbd..ece210b9d1 100644 --- a/src/Umbraco.Web/PropertyEditors/MediaPickerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MediaPickerPropertyEditor.cs @@ -1,5 +1,7 @@ -using Umbraco.Core; +using System.Collections.Generic; +using Umbraco.Core; using Umbraco.Core.Logging; +using Umbraco.Core.Models.Editors; using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors @@ -7,18 +9,47 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a media picker property editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.MediaPicker, EditorType.PropertyValue | EditorType.MacroParameter, - "Media Picker", "mediapicker", ValueType = ValueTypes.Text, Group = "media", Icon = Constants.Icons.MediaImage)] + [DataEditor( + Constants.PropertyEditors.Aliases.MediaPicker, + EditorType.PropertyValue | EditorType.MacroParameter, + "Media Picker", + "mediapicker", + ValueType = ValueTypes.Text, + Group = Constants.PropertyEditors.Groups.Media, + Icon = Constants.Icons.MediaImage)] public class MediaPickerPropertyEditor : DataEditor { + /// /// Initializes a new instance of the class. /// public MediaPickerPropertyEditor(ILogger logger) : base(logger) - { } + { + } /// protected override IConfigurationEditor CreateConfigurationEditor() => new MediaPickerConfigurationEditor(); + + protected override IDataValueEditor CreateValueEditor() => new MediaPickerPropertyValueEditor(Attribute); + + internal class MediaPickerPropertyValueEditor : DataValueEditor, IDataValueReference + { + public MediaPickerPropertyValueEditor(DataEditorAttribute attribute) : base(attribute) + { + } + + public IEnumerable GetReferences(object value) + { + var asString = value is string str ? str : value?.ToString(); + + if (string.IsNullOrEmpty(asString)) yield break; + + if (Udi.TryParse(asString, out var udi)) + yield return new UmbracoEntityReference(udi); + } + } } + + } diff --git a/src/Umbraco.Web/PropertyEditors/MemberGroupPickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MemberGroupPickerPropertyEditor.cs index 5d89024692..b8e67c863c 100644 --- a/src/Umbraco.Web/PropertyEditors/MemberGroupPickerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MemberGroupPickerPropertyEditor.cs @@ -4,7 +4,13 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.MemberGroupPicker, "Member Group Picker", "membergrouppicker", ValueType = ValueTypes.Text, Group = "People", Icon = Constants.Icons.MemberGroup)] + [DataEditor( + Constants.PropertyEditors.Aliases.MemberGroupPicker, + "Member Group Picker", + "membergrouppicker", + ValueType = ValueTypes.Text, + Group = Constants.PropertyEditors.Groups.People, + Icon = Constants.Icons.MemberGroup)] public class MemberGroupPickerPropertyEditor : DataEditor { public MemberGroupPickerPropertyEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/MemberPickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MemberPickerPropertyEditor.cs index 858582ab72..69020ba350 100644 --- a/src/Umbraco.Web/PropertyEditors/MemberPickerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MemberPickerPropertyEditor.cs @@ -4,7 +4,13 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.MemberPicker, "Member Picker", "memberpicker", ValueType = ValueTypes.String, Group = "People", Icon = Constants.Icons.Member)] + [DataEditor( + Constants.PropertyEditors.Aliases.MemberPicker, + "Member Picker", + "memberpicker", + ValueType = ValueTypes.String, + Group = Constants.PropertyEditors.Groups.People, + Icon = Constants.Icons.Member)] public class MemberPickerPropertyEditor : DataEditor { public MemberPickerPropertyEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/MultiNodePickerConfiguration.cs b/src/Umbraco.Web/PropertyEditors/MultiNodePickerConfiguration.cs index b099573b9f..81a96b5ad7 100644 --- a/src/Umbraco.Web/PropertyEditors/MultiNodePickerConfiguration.cs +++ b/src/Umbraco.Web/PropertyEditors/MultiNodePickerConfiguration.cs @@ -11,7 +11,7 @@ namespace Umbraco.Web.PropertyEditors [ConfigurationField("startNode", "Node type", "treesource")] public MultiNodePickerConfigurationTreeSource TreeSource { get; set; } - [ConfigurationField("filter", "Allow items of type", "contenttypepicker", Description = "Select the applicable content types")] + [ConfigurationField("filter", "Allow items of type", "treesourcetypepicker", Description = "Select the applicable types")] public string Filter { get; set; } [ConfigurationField("minNumber", "Minimum number of items", "number")] @@ -20,7 +20,7 @@ namespace Umbraco.Web.PropertyEditors [ConfigurationField("maxNumber", "Maximum number of items", "number")] public int MaxNumber { get; set; } - [ConfigurationField("showOpenButton", "Show open button (this feature is in preview!)", "boolean", Description = "Opens the node in a dialog")] + [ConfigurationField("showOpenButton", "Show open button", "boolean", Description = "Opens the node in a dialog")] public bool ShowOpen { get; set; } [ConfigurationField(Core.Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes, diff --git a/src/Umbraco.Web/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs index ad75e16717..fd7f735e68 100644 --- a/src/Umbraco.Web/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs @@ -1,10 +1,18 @@ -using Umbraco.Core; +using System.Collections.Generic; +using Umbraco.Core; using Umbraco.Core.Logging; +using Umbraco.Core.Models.Editors; using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.MultiNodeTreePicker, "Multinode Treepicker", "contentpicker", ValueType = ValueTypes.Text, Group = "pickers", Icon = "icon-page-add")] + [DataEditor( + Constants.PropertyEditors.Aliases.MultiNodeTreePicker, + "Multinode Treepicker", + "contentpicker", + ValueType = ValueTypes.Text, + Group = Constants.PropertyEditors.Groups.Pickers, + Icon = "icon-page-add")] public class MultiNodeTreePickerPropertyEditor : DataEditor { public MultiNodeTreePickerPropertyEditor(ILogger logger) @@ -12,5 +20,27 @@ namespace Umbraco.Web.PropertyEditors { } protected override IConfigurationEditor CreateConfigurationEditor() => new MultiNodePickerConfigurationEditor(); + + protected override IDataValueEditor CreateValueEditor() => new MultiNodeTreePickerPropertyValueEditor(Attribute); + + public class MultiNodeTreePickerPropertyValueEditor : DataValueEditor, IDataValueReference + { + public MultiNodeTreePickerPropertyValueEditor(DataEditorAttribute attribute): base(attribute) + { + + } + + public IEnumerable GetReferences(object value) + { + var asString = value == null ? string.Empty : value is string str ? str : value.ToString(); + + var udiPaths = asString.Split(','); + foreach (var udiPath in udiPaths) + if (Udi.TryParse(udiPath, out var udi)) + yield return new UmbracoEntityReference(udi); + } + } } + + } diff --git a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfiguration.cs b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfiguration.cs index 16aff6e0bf..239569478f 100644 --- a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfiguration.cs +++ b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerConfiguration.cs @@ -16,6 +16,9 @@ namespace Umbraco.Web.PropertyEditors Description = "Selecting this option allows a user to choose nodes that they normally don't have access to.")] public bool IgnoreUserStartNodes { get; set; } - + [ConfigurationField("hideAnchor", + "Hide anchor/query string input", "boolean", + Description = "Selecting this hides the anchor/query string input field in the linkpicker overlay.")] + public bool HideAnchor { get; set; } } } diff --git a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerPropertyEditor.cs index ea5d7aac8e..8af2d98018 100644 --- a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerPropertyEditor.cs @@ -4,10 +4,20 @@ using Umbraco.Core.PropertyEditors; using Umbraco.Core.Logging; using Umbraco.Core.Services; using Umbraco.Web.PublishedCache; +using System.Collections.Generic; +using Umbraco.Core.Models.Editors; +using Newtonsoft.Json; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.MultiUrlPicker, EditorType.PropertyValue, "Multi Url Picker", "multiurlpicker", ValueType = ValueTypes.Json, Group = "pickers", Icon = "icon-link")] + [DataEditor( + Constants.PropertyEditors.Aliases.MultiUrlPicker, + EditorType.PropertyValue, + "Multi Url Picker", + "multiurlpicker", + ValueType = ValueTypes.Json, + Group = Constants.PropertyEditors.Groups.Pickers, + Icon = "icon-link")] public class MultiUrlPickerPropertyEditor : DataEditor { private readonly IEntityService _entityService; @@ -18,7 +28,7 @@ namespace Umbraco.Web.PropertyEditors _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); } - + protected override IConfigurationEditor CreateConfigurationEditor() => new MultiUrlPickerConfigurationEditor(); protected override IDataValueEditor CreateValueEditor() => new MultiUrlPickerValueEditor(_entityService, _publishedSnapshotAccessor, Logger, Attribute); diff --git a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs index aa8fa73c7a..853e995ed8 100644 --- a/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -15,7 +15,7 @@ using Umbraco.Web.PublishedCache; namespace Umbraco.Web.PropertyEditors { - public class MultiUrlPickerValueEditor : DataValueEditor + public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference { private readonly IEntityService _entityService; private readonly ILogger _logger; @@ -174,5 +174,22 @@ namespace Umbraco.Web.PropertyEditors [DataMember(Name = "queryString")] public string QueryString { get; set; } } + + public IEnumerable GetReferences(object value) + { + var asString = value == null ? string.Empty : value is string str ? str : value.ToString(); + + if (string.IsNullOrEmpty(asString)) yield break; + + var links = JsonConvert.DeserializeObject>(asString); + foreach (var link in links) + { + if (link.Udi != null) // Links can be absolute links without a Udi + { + yield return new UmbracoEntityReference(link.Udi); + } + + } + } } } diff --git a/src/Umbraco.Web/PropertyEditors/MultipleTextStringPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/MultipleTextStringPropertyEditor.cs index 141bdea7b6..fa82bc555c 100644 --- a/src/Umbraco.Web/PropertyEditors/MultipleTextStringPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/MultipleTextStringPropertyEditor.cs @@ -1,11 +1,15 @@ using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using Newtonsoft.Json.Linq; using Umbraco.Core; +using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Editors; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.PropertyEditors.Validators; using Umbraco.Core.Services; namespace Umbraco.Web.PropertyEditors @@ -13,7 +17,13 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a multiple text string property editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.MultipleTextstring, "Repeatable textstrings", "multipletextbox", ValueType = ValueTypes.Text, Icon="icon-ordered-list", Group="lists")] + [DataEditor( + Constants.PropertyEditors.Aliases.MultipleTextstring, + "Repeatable textstrings", + "multipletextbox", + ValueType = ValueTypes.Text, + Group = Constants.PropertyEditors.Groups.Lists, + Icon = "icon-ordered-list")] public class MultipleTextStringPropertyEditor : DataEditor { /// @@ -56,7 +66,7 @@ namespace Umbraco.Web.PropertyEditors } if (!(editorValue.DataTypeConfiguration is MultipleTextStringConfiguration config)) - throw new Exception("panic"); + throw new PanicException($"editorValue.DataTypeConfiguration is {editorValue.DataTypeConfiguration.GetType()} but must be {typeof(MultipleTextStringConfiguration)}"); var max = config.Maximum; //The legacy property editor saved this data as new line delimited! strange but we have to maintain that. @@ -79,7 +89,7 @@ namespace Umbraco.Web.PropertyEditors /// /// /// - /// + /// /// /// /// @@ -90,10 +100,40 @@ namespace Umbraco.Web.PropertyEditors var val = property.GetValue(culture, segment); return val?.ToString().Split(new[] {Environment.NewLine}, StringSplitOptions.RemoveEmptyEntries) .Select(x => JObject.FromObject(new {value = x})) ?? new JObject[] { }; - - } + /// + /// A custom FormatValidator is used as for multiple text strings, each string should individually be checked + /// against the configured regular expression, rather than the JSON representing all the strings as a whole. + /// + public override IValueFormatValidator FormatValidator => new MultipleTextStringFormatValidator(); + } + + internal class MultipleTextStringFormatValidator : IValueFormatValidator + { + public IEnumerable ValidateFormat(object value, string valueType, string format) + { + var asArray = value as JArray; + if (asArray == null) + { + return Enumerable.Empty(); + } + + var textStrings = asArray.OfType() + .Where(x => x["value"] != null) + .Select(x => x["value"].Value()); + var textStringValidator = new RegexValidator(); + foreach (var textString in textStrings) + { + var validationResults = textStringValidator.ValidateFormat(textString, valueType, format).ToList(); + if (validationResults.Any()) + { + return validationResults; + } + } + + return Enumerable.Empty(); + } } } } diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentConfiguration.cs b/src/Umbraco.Web/PropertyEditors/NestedContentConfiguration.cs index b904a2250d..0f53207462 100644 --- a/src/Umbraco.Web/PropertyEditors/NestedContentConfiguration.cs +++ b/src/Umbraco.Web/PropertyEditors/NestedContentConfiguration.cs @@ -8,22 +8,22 @@ namespace Umbraco.Web.PropertyEditors /// public class NestedContentConfiguration { - [ConfigurationField("contentTypes", "Document types", "views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html", Description = "Select the document types to use as the item blueprints. Only \"element\" types can be used.")] + [ConfigurationField("contentTypes", "Element Types", "views/propertyeditors/nestedcontent/nestedcontent.doctypepicker.html", Description = "Select the Element Types to use as models for the items.")] public ContentType[] ContentTypes { get; set; } - [ConfigurationField("minItems", "Min Items", "number", Description = "Set the minimum number of items allowed.")] + [ConfigurationField("minItems", "Min Items", "number", Description = "Minimum number of items allowed.")] public int? MinItems { get; set; } - [ConfigurationField("maxItems", "Max Items", "number", Description = "Set the maximum number of items allowed.")] + [ConfigurationField("maxItems", "Max Items", "number", Description = "Maximum number of items allowed.")] public int? MaxItems { get; set; } - [ConfigurationField("confirmDeletes", "Confirm Deletes", "boolean", Description = "Set whether item deletions should require confirming.")] + [ConfigurationField("confirmDeletes", "Confirm Deletes", "boolean", Description = "Requires editor confirmation for delete actions.")] public bool ConfirmDeletes { get; set; } = true; - [ConfigurationField("showIcons", "Show Icons", "boolean", Description = "Set whether to show the items doc type icon in the list.")] + [ConfigurationField("showIcons", "Show Icons", "boolean", Description = "Show the Element Type icons.")] public bool ShowIcons { get; set; } = true; - [ConfigurationField("hideLabel", "Hide Label", "boolean", Description = "Set whether to hide the editor label and have the list take up the full width of the editor window.")] + [ConfigurationField("hideLabel", "Hide Label", "boolean", Description = "Hide the property label and let the item list span the full width of the editor window.")] public bool HideLabel { get; set; } public class ContentType diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentController.cs b/src/Umbraco.Web/PropertyEditors/NestedContentController.cs index 8d144c5904..590a286c5d 100644 --- a/src/Umbraco.Web/PropertyEditors/NestedContentController.cs +++ b/src/Umbraco.Web/PropertyEditors/NestedContentController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using Umbraco.Core.Services; using Umbraco.Web.Editors; using Umbraco.Web.Mvc; @@ -11,8 +12,8 @@ namespace Umbraco.Web.PropertyEditors [System.Web.Http.HttpGet] public IEnumerable GetContentTypes() { - return Services.ContentTypeService.GetAll() - .Where(x => x.IsElement) + return Services.ContentTypeService + .GetAllElementTypes() .OrderBy(x => x.SortOrder) .Select(x => new { diff --git a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs index 6dee2f78b5..564630c574 100644 --- a/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/NestedContentPropertyEditor.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -18,30 +19,31 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a nested content property editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.NestedContent, "Nested Content", "nestedcontent", ValueType = "JSON", Group = "lists", Icon = "icon-thumbnail-list")] + [DataEditor( + Constants.PropertyEditors.Aliases.NestedContent, + "Nested Content", + "nestedcontent", + ValueType = ValueTypes.Json, + Group = Constants.PropertyEditors.Groups.Lists, + Icon = "icon-thumbnail-list")] public class NestedContentPropertyEditor : DataEditor { private readonly Lazy _propertyEditors; - + private readonly IDataTypeService _dataTypeService; + private readonly IContentTypeService _contentTypeService; internal const string ContentTypeAliasPropertyKey = "ncContentTypeAlias"; - public NestedContentPropertyEditor(ILogger logger, Lazy propertyEditors) + public NestedContentPropertyEditor(ILogger logger, Lazy propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService) : base (logger) { _propertyEditors = propertyEditors; + _dataTypeService = dataTypeService; + _contentTypeService = contentTypeService; } // has to be lazy else circular dep in ctor private PropertyEditorCollection PropertyEditors => _propertyEditors.Value; - private static IContentType GetElementType(JObject item) - { - var contentTypeAlias = item[ContentTypeAliasPropertyKey]?.ToObject(); - return string.IsNullOrEmpty(contentTypeAlias) - ? null - : Current.Services.ContentTypeService.Get(contentTypeAlias); - } - #region Pre Value Editor protected override IConfigurationEditor CreateConfigurationEditor() => new NestedContentConfigurationEditor(); @@ -50,21 +52,23 @@ namespace Umbraco.Web.PropertyEditors #region Value Editor - protected override IDataValueEditor CreateValueEditor() => new NestedContentPropertyValueEditor(Attribute, PropertyEditors); + protected override IDataValueEditor CreateValueEditor() => new NestedContentPropertyValueEditor(Attribute, PropertyEditors, _dataTypeService, _contentTypeService); - internal class NestedContentPropertyValueEditor : DataValueEditor + internal class NestedContentPropertyValueEditor : DataValueEditor, IDataValueReference { private readonly PropertyEditorCollection _propertyEditors; - - public NestedContentPropertyValueEditor(DataEditorAttribute attribute, PropertyEditorCollection propertyEditors) + private readonly IDataTypeService _dataTypeService; + private readonly NestedContentValues _nestedContentValues; + + public NestedContentPropertyValueEditor(DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService) : base(attribute) { _propertyEditors = propertyEditors; - Validators.Add(new NestedContentValidator(propertyEditors)); + _dataTypeService = dataTypeService; + _nestedContentValues = new NestedContentValues(contentTypeService); + Validators.Add(new NestedContentValidator(propertyEditors, dataTypeService, _nestedContentValues)); } - internal ServiceContext Services => Current.Services; - /// public override object Configuration { @@ -85,56 +89,47 @@ namespace Umbraco.Web.PropertyEditors public override string ConvertDbToString(PropertyType propertyType, object propertyValue, IDataTypeService dataTypeService) { - if (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString())) + var vals = _nestedContentValues.GetPropertyValues(propertyValue, out var deserialized).ToList(); + + if (vals.Count == 0) return string.Empty; - var value = JsonConvert.DeserializeObject>(propertyValue.ToString()); - if (value == null) - return string.Empty; - - foreach (var o in value) + foreach (var row in vals) { - var propValues = (JObject) o; - - var contentType = GetElementType(propValues); - if (contentType == null) - continue; - - var propAliases = propValues.Properties().Select(x => x.Name).ToArray(); - foreach (var propAlias in propAliases) + if (row.PropType == null) { - var propType = contentType.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == propAlias); - if (propType == null) + // type not found, and property is not system: just delete the value + if (IsSystemPropertyKey(row.PropKey) == false) + row.JsonRowValue[row.PropKey] = null; + } + else + { + try { - // type not found, and property is not system: just delete the value - if (IsSystemPropertyKey(propAlias) == false) - propValues[propAlias] = null; + // convert the value, and store the converted value + var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; + if (propEditor == null) continue; + + var tempConfig = dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; + var valEditor = propEditor.GetValueEditor(tempConfig); + var convValue = valEditor.ConvertDbToString(row.PropType, row.JsonRowValue[row.PropKey]?.ToString(), dataTypeService); + row.JsonRowValue[row.PropKey] = convValue; } - else + catch (InvalidOperationException) { - try - { - // convert the value, and store the converted value - var propEditor = _propertyEditors[propType.PropertyEditorAlias]; - var tempConfig = dataTypeService.GetDataType(propType.DataTypeId).Configuration; - var valEditor = propEditor.GetValueEditor(tempConfig); - var convValue = valEditor.ConvertDbToString(propType, propValues[propAlias]?.ToString(), dataTypeService); - propValues[propAlias] = convValue; - } - catch (InvalidOperationException) - { - // deal with weird situations by ignoring them (no comment) - propValues[propAlias] = null; - } + // deal with weird situations by ignoring them (no comment) + row.JsonRowValue[row.PropKey] = null; } } } - return JsonConvert.SerializeObject(value).ToXmlString(); + return JsonConvert.SerializeObject(deserialized).ToXmlString(); } #endregion + + #region Convert database // editor // note: there is NO variant support here @@ -142,60 +137,53 @@ namespace Umbraco.Web.PropertyEditors public override object ToEditor(Property property, IDataTypeService dataTypeService, string culture = null, string segment = null) { var val = property.GetValue(culture, segment); - if (val == null || string.IsNullOrWhiteSpace(val.ToString())) + + var vals = _nestedContentValues.GetPropertyValues(val, out var deserialized).ToList(); + + if (vals.Count == 0) return string.Empty; - var value = JsonConvert.DeserializeObject>(val.ToString()); - if (value == null) - return string.Empty; - - foreach (var o in value) + foreach (var row in vals) { - var propValues = (JObject) o; - - var contentType = GetElementType(propValues); - if (contentType == null) - continue; - - var propAliases = propValues.Properties().Select(x => x.Name).ToArray(); - foreach (var propAlias in propAliases) + if (row.PropType == null) { - var propType = contentType.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == propAlias); - if (propType == null) + // type not found, and property is not system: just delete the value + if (IsSystemPropertyKey(row.PropKey) == false) + row.JsonRowValue[row.PropKey] = null; + } + else + { + try { - // type not found, and property is not system: just delete the value - if (IsSystemPropertyKey(propAlias) == false) - propValues[propAlias] = null; + // create a temp property with the value + // - force it to be culture invariant as NC can't handle culture variant element properties + row.PropType.Variations = ContentVariation.Nothing; + var tempProp = new Property(row.PropType); + tempProp.SetValue(row.JsonRowValue[row.PropKey] == null ? null : row.JsonRowValue[row.PropKey].ToString()); + + // convert that temp property, and store the converted value + var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; + if (propEditor == null) + { + row.JsonRowValue[row.PropKey] = tempProp.GetValue()?.ToString(); + continue; + } + + var tempConfig = dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; + var valEditor = propEditor.GetValueEditor(tempConfig); + var convValue = valEditor.ToEditor(tempProp, dataTypeService); + row.JsonRowValue[row.PropKey] = convValue == null ? null : JToken.FromObject(convValue); } - else + catch (InvalidOperationException) { - try - { - // create a temp property with the value - // - force it to be culture invariant as NC can't handle culture variant element properties - propType.Variations = ContentVariation.Nothing; - var tempProp = new Property(propType); - tempProp.SetValue(propValues[propAlias] == null ? null : propValues[propAlias].ToString()); - - // convert that temp property, and store the converted value - var propEditor = _propertyEditors[propType.PropertyEditorAlias]; - var tempConfig = dataTypeService.GetDataType(propType.DataTypeId).Configuration; - var valEditor = propEditor.GetValueEditor(tempConfig); - var convValue = valEditor.ToEditor(tempProp, dataTypeService); - propValues[propAlias] = convValue == null ? null : JToken.FromObject(convValue); - } - catch (InvalidOperationException) - { - // deal with weird situations by ignoring them (no comment) - propValues[propAlias] = null; - } + // deal with weird situations by ignoring them (no comment) + row.JsonRowValue[row.PropKey] = null; } - } } // return json - return value; + return deserialized; } public override object FromEditor(ContentPropertyData editorValue, object currentValue) @@ -203,134 +191,229 @@ namespace Umbraco.Web.PropertyEditors if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString())) return null; - var value = JsonConvert.DeserializeObject>(editorValue.Value.ToString()); - if (value == null) - return null; + var vals = _nestedContentValues.GetPropertyValues(editorValue.Value, out var deserialized).ToList(); - // Issue #38 - Keep recursive property lookups working - if (!value.Any()) - return null; + if (vals.Count == 0) + return string.Empty; - // Process value - for (var i = 0; i < value.Count; i++) + foreach (var row in vals) { - var o = value[i]; - var propValues = ((JObject)o); - - var contentType = GetElementType(propValues); - if (contentType == null) + if (row.PropType == null) { - continue; + // type not found, and property is not system: just delete the value + if (IsSystemPropertyKey(row.PropKey) == false) + row.JsonRowValue[row.PropKey] = null; } - - var propValueKeys = propValues.Properties().Select(x => x.Name).ToArray(); - - foreach (var propKey in propValueKeys) + else { - var propType = contentType.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == propKey); - if (propType == null) - { - if (IsSystemPropertyKey(propKey) == false) - { - // Property missing so just delete the value - propValues[propKey] = null; - } - } - else - { - // Fetch the property types prevalue - var propConfiguration = Services.DataTypeService.GetDataType(propType.DataTypeId).Configuration; + // Fetch the property types prevalue + var propConfiguration = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; - // Lookup the property editor - var propEditor = _propertyEditors[propType.PropertyEditorAlias]; + // Lookup the property editor + var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; + if (propEditor == null) continue; - // Create a fake content property data object - var contentPropData = new ContentPropertyData(propValues[propKey], propConfiguration); + // Create a fake content property data object + var contentPropData = new ContentPropertyData(row.JsonRowValue[row.PropKey], propConfiguration); - // Get the property editor to do it's conversion - var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, propValues[propKey]); - - // Store the value back - propValues[propKey] = (newValue == null) ? null : JToken.FromObject(newValue); - } + // Get the property editor to do it's conversion + var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, row.JsonRowValue[row.PropKey]); + // Store the value back + row.JsonRowValue[row.PropKey] = (newValue == null) ? null : JToken.FromObject(newValue); } } - return JsonConvert.SerializeObject(value); + // return json + return JsonConvert.SerializeObject(deserialized); } - #endregion + + public IEnumerable GetReferences(object value) + { + var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); + + var result = new List(); + + foreach (var row in _nestedContentValues.GetPropertyValues(rawJson, out _)) + { + if (row.PropType == null) continue; + + var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; + + var valueEditor = propEditor?.GetValueEditor(); + if (!(valueEditor is IDataValueReference reference)) continue; + + var val = row.JsonRowValue[row.PropKey]?.ToString(); + + var refs = reference.GetReferences(val); + + result.AddRange(refs); + } + + return result; + } } internal class NestedContentValidator : IValueValidator { private readonly PropertyEditorCollection _propertyEditors; + private readonly IDataTypeService _dataTypeService; + private readonly NestedContentValues _nestedContentValues; - public NestedContentValidator(PropertyEditorCollection propertyEditors) + public NestedContentValidator(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, NestedContentValues nestedContentValues) { _propertyEditors = propertyEditors; + _dataTypeService = dataTypeService; + _nestedContentValues = nestedContentValues; } public IEnumerable Validate(object rawValue, string valueType, object dataTypeConfiguration) { - if (rawValue == null) - yield break; + var validationResults = new List(); - var value = JsonConvert.DeserializeObject>(rawValue.ToString()); - if (value == null) - yield break; - - var dataTypeService = Current.Services.DataTypeService; - for (var i = 0; i < value.Count; i++) + foreach(var row in _nestedContentValues.GetPropertyValues(rawValue, out _)) { - var o = value[i]; - var propValues = (JObject) o; + if (row.PropType == null) continue; - var contentType = GetElementType(propValues); - if (contentType == null) continue; + var config = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; + var propertyEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; + if (propertyEditor == null) continue; - var propValueKeys = propValues.Properties().Select(x => x.Name).ToArray(); - - foreach (var propKey in propValueKeys) + foreach (var validator in propertyEditor.GetValueEditor().Validators) { - var propType = contentType.CompositionPropertyTypes.FirstOrDefault(x => x.Alias == propKey); - if (propType != null) + foreach (var result in validator.Validate(row.JsonRowValue[row.PropKey], propertyEditor.GetValueEditor().ValueType, config)) { - var config = dataTypeService.GetDataType(propType.DataTypeId).Configuration; - var propertyEditor = _propertyEditors[propType.PropertyEditorAlias]; + result.ErrorMessage = "Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' " + result.ErrorMessage; + validationResults.Add(result); + } + } - foreach (var validator in propertyEditor.GetValueEditor().Validators) - { - foreach (var result in validator.Validate(propValues[propKey], propertyEditor.GetValueEditor().ValueType, config)) - { - result.ErrorMessage = "Item " + (i + 1) + " '" + propType.Name + "' " + result.ErrorMessage; - yield return result; - } - } + // Check mandatory + if (row.PropType.Mandatory) + { + if (row.JsonRowValue[row.PropKey] == null) + { + var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage) + ? $"'{row.PropType.Name}' cannot be null" + : row.PropType.MandatoryMessage; + validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); + } + else if (row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace() || (row.JsonRowValue[row.PropKey].Type == JTokenType.Array && !row.JsonRowValue[row.PropKey].HasValues)) + { + var message = string.IsNullOrWhiteSpace(row.PropType.MandatoryMessage) + ? $"'{row.PropType.Name}' cannot be empty" + : row.PropType.MandatoryMessage; + validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); + } + } - // Check mandatory - if (propType.Mandatory) - { - if (propValues[propKey] == null) - yield return new ValidationResult("Item " + (i + 1) + " '" + propType.Name + "' cannot be null", new[] { propKey }); - else if (propValues[propKey].ToString().IsNullOrWhiteSpace() || (propValues[propKey].Type == JTokenType.Array && !propValues[propKey].HasValues)) - yield return new ValidationResult("Item " + (i + 1) + " '" + propType.Name + "' cannot be empty", new[] { propKey }); - } - - // Check regex - if (!propType.ValidationRegExp.IsNullOrWhiteSpace() - && propValues[propKey] != null && !propValues[propKey].ToString().IsNullOrWhiteSpace()) - { - var regex = new Regex(propType.ValidationRegExp); - if (!regex.IsMatch(propValues[propKey].ToString())) - { - yield return new ValidationResult("Item " + (i + 1) + " '" + propType.Name + "' is invalid, it does not match the correct pattern", new[] { propKey }); - } - } + // Check regex + if (!row.PropType.ValidationRegExp.IsNullOrWhiteSpace() + && row.JsonRowValue[row.PropKey] != null && !row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace()) + { + var regex = new Regex(row.PropType.ValidationRegExp); + if (!regex.IsMatch(row.JsonRowValue[row.PropKey].ToString())) + { + var message = string.IsNullOrWhiteSpace(row.PropType.ValidationRegExpMessage) + ? $"'{row.PropType.Name}' is invalid, it does not match the correct pattern" + : row.PropType.ValidationRegExpMessage; + validationResults.Add(new ValidationResult($"Item {(row.RowIndex + 1)}: {message}", new[] { row.PropKey })); } } } + + return validationResults; + } + } + + internal class NestedContentValues + { + private readonly Lazy> _contentTypes; + + public NestedContentValues(IContentTypeService contentTypeService) + { + _contentTypes = new Lazy>(() => contentTypeService.GetAll().ToDictionary(c => c.Alias)); + } + + private IContentType GetElementType(JObject item) + { + var contentTypeAlias = item[ContentTypeAliasPropertyKey]?.ToObject() ?? string.Empty; + _contentTypes.Value.TryGetValue(contentTypeAlias, out var contentType); + return contentType; + } + + public IEnumerable GetPropertyValues(object propertyValue, out List deserialized) + { + var rowValues = new List(); + + deserialized = null; + + if (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString())) + return Enumerable.Empty(); + + deserialized = JsonConvert.DeserializeObject>(propertyValue.ToString()); + + // There was a note here about checking if the result had zero items and if so it would return null, so we'll continue to do that + // The original note was: "Issue #38 - Keep recursive property lookups working" + // Which is from the original NC tracker: https://github.com/umco/umbraco-nested-content/issues/38 + // This check should be used everywhere when iterating NC prop values, instead of just the one previous place so that + // empty values don't get persisted when there is nothing, it should actually be null. + if (deserialized == null || deserialized.Count == 0) + return Enumerable.Empty(); + + var index = 0; + + foreach (var o in deserialized) + { + var propValues = o; + + var contentType = GetElementType(propValues); + if (contentType == null) + continue; + + var propertyTypes = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x); + var propAliases = propValues.Properties().Select(x => x.Name); + foreach (var propAlias in propAliases) + { + propertyTypes.TryGetValue(propAlias, out var propType); + rowValues.Add(new RowValue(propAlias, propType, propValues, index)); + } + index++; + } + + return rowValues; + } + + internal class RowValue + { + public RowValue(string propKey, PropertyType propType, JObject propValues, int index) + { + PropKey = propKey ?? throw new ArgumentNullException(nameof(propKey)); + PropType = propType; + JsonRowValue = propValues ?? throw new ArgumentNullException(nameof(propValues)); + RowIndex = index; + } + + /// + /// The current property key being iterated for the row value + /// + public string PropKey { get; } + + /// + /// The of the value (if any), this may be null + /// + public PropertyType PropType { get; } + + /// + /// The json values for the current row + /// + public JObject JsonRowValue { get; } + + /// + /// The Nested Content row index + /// + public int RowIndex { get; } } } diff --git a/src/Umbraco.Web/PropertyEditors/ParameterEditors/ContentTypeParameterEditor.cs b/src/Umbraco.Web/PropertyEditors/ParameterEditors/ContentTypeParameterEditor.cs index cdab78d119..149243f122 100644 --- a/src/Umbraco.Web/PropertyEditors/ParameterEditors/ContentTypeParameterEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ParameterEditors/ContentTypeParameterEditor.cs @@ -6,7 +6,11 @@ namespace Umbraco.Web.PropertyEditors.ParameterEditors /// /// Represents a content type parameter editor. /// - [DataEditor("contentType", EditorType.MacroParameter, "Content Type Picker", "entitypicker")] + [DataEditor( + "contentType", + EditorType.MacroParameter, + "Content Type Picker", + "entitypicker")] public class ContentTypeParameterEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleContentPickerParameterEditor.cs b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleContentPickerParameterEditor.cs index 3d0d0eb923..0ca657ac39 100644 --- a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleContentPickerParameterEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleContentPickerParameterEditor.cs @@ -7,7 +7,11 @@ namespace Umbraco.Web.PropertyEditors.ParameterEditors /// /// Represents a parameter editor of some sort. /// - [DataEditor(Constants.PropertyEditors.Aliases.MultiNodeTreePicker, EditorType.MacroParameter, "Multiple Content Picker", "contentpicker")] + [DataEditor( + Constants.PropertyEditors.Aliases.MultiNodeTreePicker, + EditorType.MacroParameter, + "Multiple Content Picker", + "contentpicker")] public class MultipleContentPickerParameterEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleContentTypeParameterEditor.cs b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleContentTypeParameterEditor.cs index 74bcafe30f..c8f19f8acd 100644 --- a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleContentTypeParameterEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleContentTypeParameterEditor.cs @@ -3,7 +3,11 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors.ParameterEditors { - [DataEditor("contentTypeMultiple", EditorType.MacroParameter, "Multiple Content Type Picker", "entitypicker")] + [DataEditor( + "contentTypeMultiple", + EditorType.MacroParameter, + "Multiple Content Type Picker", + "entitypicker")] public class MultipleContentTypeParameterEditor : DataEditor { public MultipleContentTypeParameterEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleMediaPickerParameterEditor.cs b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleMediaPickerParameterEditor.cs new file mode 100644 index 0000000000..1208a5eecc --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultipleMediaPickerParameterEditor.cs @@ -0,0 +1,27 @@ +using Umbraco.Core; +using Umbraco.Core.Logging; +using Umbraco.Core.PropertyEditors; + +namespace Umbraco.Web.PropertyEditors.ParameterEditors +{ + /// + /// Represents a multiple media picker macro parameter editor. + /// + [DataEditor( + Constants.PropertyEditors.Aliases.MultipleMediaPicker, + EditorType.MacroParameter, + "Multiple Media Picker", + "mediapicker", + ValueType = ValueTypes.Text)] + public class MultipleMediaPickerParameterEditor : DataEditor + { + /// + /// Initializes a new instance of the class. + /// + public MultipleMediaPickerParameterEditor(ILogger logger) + : base(logger) + { + DefaultConfiguration.Add("multiPicker", "1"); + } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultiplePropertyGroupParameterEditor.cs b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultiplePropertyGroupParameterEditor.cs index 87ddfb0b54..2a7079d578 100644 --- a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultiplePropertyGroupParameterEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultiplePropertyGroupParameterEditor.cs @@ -3,7 +3,11 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors.ParameterEditors { - [DataEditor("tabPickerMultiple", EditorType.MacroParameter, "Multiple Tab Picker", "entitypicker")] + [DataEditor( + "tabPickerMultiple", + EditorType.MacroParameter, + "Multiple Tab Picker", + "entitypicker")] public class MultiplePropertyGroupParameterEditor : DataEditor { public MultiplePropertyGroupParameterEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultiplePropertyTypeParameterEditor.cs b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultiplePropertyTypeParameterEditor.cs index 124325dbd7..fe1432a655 100644 --- a/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultiplePropertyTypeParameterEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ParameterEditors/MultiplePropertyTypeParameterEditor.cs @@ -3,7 +3,11 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors.ParameterEditors { - [DataEditor("propertyTypePickerMultiple", EditorType.MacroParameter, "Multiple Property Type Picker", "entitypicker")] + [DataEditor( + "propertyTypePickerMultiple", + EditorType.MacroParameter, + "Multiple Property Type Picker", + "entitypicker")] public class MultiplePropertyTypeParameterEditor : DataEditor { public MultiplePropertyTypeParameterEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/ParameterEditors/PropertyGroupParameterEditor.cs b/src/Umbraco.Web/PropertyEditors/ParameterEditors/PropertyGroupParameterEditor.cs index 76fa34341d..65ce595852 100644 --- a/src/Umbraco.Web/PropertyEditors/ParameterEditors/PropertyGroupParameterEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ParameterEditors/PropertyGroupParameterEditor.cs @@ -3,7 +3,11 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors.ParameterEditors { - [DataEditor("tabPicker", EditorType.MacroParameter, "Tab Picker", "entitypicker")] + [DataEditor( + "tabPicker", + EditorType.MacroParameter, + "Tab Picker", + "entitypicker")] public class PropertyGroupParameterEditor : DataEditor { public PropertyGroupParameterEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/ParameterEditors/PropertyTypeParameterEditor.cs b/src/Umbraco.Web/PropertyEditors/ParameterEditors/PropertyTypeParameterEditor.cs index b84749da27..4ea7c0ebdc 100644 --- a/src/Umbraco.Web/PropertyEditors/ParameterEditors/PropertyTypeParameterEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/ParameterEditors/PropertyTypeParameterEditor.cs @@ -3,7 +3,11 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors.ParameterEditors { - [DataEditor("propertyTypePicker", EditorType.MacroParameter, "Property Type Picker", "entitypicker")] + [DataEditor( + "propertyTypePicker", + EditorType.MacroParameter, + "Property Type Picker", + "entitypicker")] public class PropertyTypeParameterEditor : DataEditor { public PropertyTypeParameterEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/RadioButtonsPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/RadioButtonsPropertyEditor.cs index 601728189c..f729decd2c 100644 --- a/src/Umbraco.Web/PropertyEditors/RadioButtonsPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/RadioButtonsPropertyEditor.cs @@ -8,7 +8,13 @@ namespace Umbraco.Web.PropertyEditors /// /// A property editor to allow the individual selection of pre-defined items. /// - [DataEditor(Constants.PropertyEditors.Aliases.RadioButtonList, "Radio button list", "radiobuttons", ValueType = ValueTypes.String, Group="lists", Icon="icon-target")] + [DataEditor( + Constants.PropertyEditors.Aliases.RadioButtonList, + "Radio button list", + "radiobuttons", + ValueType = ValueTypes.String, + Group = Constants.PropertyEditors.Groups.Lists, + Icon = "icon-target")] public class RadioButtonsPropertyEditor : DataEditor { private readonly ILocalizedTextService _textService; diff --git a/src/Umbraco.Web/PropertyEditors/RichTextConfiguration.cs b/src/Umbraco.Web/PropertyEditors/RichTextConfiguration.cs index bd153c8e2f..c6d4e3ce70 100644 --- a/src/Umbraco.Web/PropertyEditors/RichTextConfiguration.cs +++ b/src/Umbraco.Web/PropertyEditors/RichTextConfiguration.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json.Linq; +using Umbraco.Core; using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors @@ -19,5 +20,9 @@ namespace Umbraco.Web.PropertyEditors "Ignore User Start Nodes", "boolean", Description = "Selecting this option allows a user to choose nodes that they normally don't have access to.")] public bool IgnoreUserStartNodes { get; set; } + + [ConfigurationField("mediaParentId", "Image Upload Folder", "MediaFolderPicker", + Description = "Choose the upload location of pasted images")] + public GuidUdi MediaParentId { get; set; } } } diff --git a/src/Umbraco.Web/PropertyEditors/RichTextEditorPastedImages.cs b/src/Umbraco.Web/PropertyEditors/RichTextEditorPastedImages.cs new file mode 100644 index 0000000000..0b2a607f8b --- /dev/null +++ b/src/Umbraco.Web/PropertyEditors/RichTextEditorPastedImages.cs @@ -0,0 +1,143 @@ +using HtmlAgilityPack; +using System; +using System.Collections.Generic; +using System.IO; +using Umbraco.Core; +using Umbraco.Core.Exceptions; +using Umbraco.Core.IO; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Services; +using Umbraco.Web.Templates; + +namespace Umbraco.Web.PropertyEditors +{ + public sealed class RichTextEditorPastedImages + { + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly ILogger _logger; + private readonly IMediaService _mediaService; + private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; + + const string TemporaryImageDataAttribute = "data-tmpimg"; + + public RichTextEditorPastedImages(IUmbracoContextAccessor umbracoContextAccessor, ILogger logger, IMediaService mediaService, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider) + { + _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); + _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider ?? throw new ArgumentNullException(nameof(contentTypeBaseServiceProvider)); + } + + /// + /// Used by the RTE (and grid RTE) for drag/drop/persisting images + /// + /// + /// + /// + /// + internal string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId) + { + // Find all img's that has data-tmpimg attribute + // Use HTML Agility Pack - https://html-agility-pack.net + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(html); + + var tmpImages = htmlDoc.DocumentNode.SelectNodes($"//img[@{TemporaryImageDataAttribute}]"); + if (tmpImages == null || tmpImages.Count == 0) + return html; + + // An array to contain a list of URLs that + // we have already processed to avoid dupes + var uploadedImages = new Dictionary(); + + foreach (var img in tmpImages) + { + // The data attribute contains the path to the tmp img to persist as a media item + var tmpImgPath = img.GetAttributeValue(TemporaryImageDataAttribute, string.Empty); + + if (string.IsNullOrEmpty(tmpImgPath)) + continue; + + var absoluteTempImagePath = IOHelper.MapPath(tmpImgPath); + var fileName = Path.GetFileName(absoluteTempImagePath); + var safeFileName = fileName.ToSafeFileName(); + + var mediaItemName = safeFileName.ToFriendlyName(); + IMedia mediaFile; + GuidUdi udi; + + if (uploadedImages.ContainsKey(tmpImgPath) == false) + { + if (mediaParentFolder == Guid.Empty) + mediaFile = _mediaService.CreateMedia(mediaItemName, Constants.System.Root, Constants.Conventions.MediaTypes.Image, userId); + else + mediaFile = _mediaService.CreateMedia(mediaItemName, mediaParentFolder, Constants.Conventions.MediaTypes.Image, userId); + + var fileInfo = new FileInfo(absoluteTempImagePath); + + var fileStream = fileInfo.OpenReadWithRetry(); + if (fileStream == null) throw new InvalidOperationException("Could not acquire file stream"); + using (fileStream) + { + mediaFile.SetValue(_contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream); + } + + _mediaService.Save(mediaFile, userId); + + udi = mediaFile.GetUdi(); + } + else + { + // Already been uploaded & we have it's UDI + udi = uploadedImages[tmpImgPath]; + } + + // Add the UDI to the img element as new data attribute + img.SetAttributeValue("data-udi", udi.ToString()); + + // Get the new persisted image url + var mediaTyped = _umbracoContextAccessor?.UmbracoContext?.Media.GetById(udi.Guid); + if (mediaTyped == null) + throw new PanicException($"Could not find media by id {udi.Guid} or there was no UmbracoContext available."); + + var location = mediaTyped.Url; + + // Find the width & height attributes as we need to set the imageprocessor QueryString + var width = img.GetAttributeValue("width", int.MinValue); + var height = img.GetAttributeValue("height", int.MinValue); + + if (width != int.MinValue && height != int.MinValue) + { + location = $"{location}?width={width}&height={height}&mode=max"; + } + + img.SetAttributeValue("src", location); + + // Remove the data attribute (so we do not re-process this) + img.Attributes.Remove(TemporaryImageDataAttribute); + + // Add to the dictionary to avoid dupes + if (uploadedImages.ContainsKey(tmpImgPath) == false) + { + uploadedImages.Add(tmpImgPath, udi); + + // Delete folder & image now its saved in media + // The folder should contain one image - as a unique guid folder created + // for each image uploaded from TinyMceController + var folderName = Path.GetDirectoryName(absoluteTempImagePath); + try + { + Directory.Delete(folderName, true); + } + catch (Exception ex) + { + _logger.Error(typeof(HtmlImageSourceParser), ex, "Could not delete temp file or folder {FileName}", absoluteTempImagePath); + } + } + } + + return htmlDoc.DocumentNode.OuterHtml; + } + } +} diff --git a/src/Umbraco.Web/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/RichTextPropertyEditor.cs index 297f9b7fb8..427e36b37a 100644 --- a/src/Umbraco.Web/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/RichTextPropertyEditor.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; using Umbraco.Core; using Umbraco.Core.Logging; -using Umbraco.Core.Macros; using Umbraco.Core.Models; +using Umbraco.Core.Models.Editors; using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; using Umbraco.Examine; @@ -15,21 +16,39 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a rich text property editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.TinyMce, "Rich Text Editor", "rte", ValueType = ValueTypes.Text, HideLabel = false, Group="Rich Content", Icon="icon-browser-window")] + [DataEditor( + Constants.PropertyEditors.Aliases.TinyMce, + "Rich Text Editor", + "rte", + ValueType = ValueTypes.Text, + HideLabel = false, + Group = Constants.PropertyEditors.Groups.RichContent, + Icon = "icon-browser-window")] public class RichTextPropertyEditor : DataEditor { + private IUmbracoContextAccessor _umbracoContextAccessor; + private readonly HtmlImageSourceParser _imageSourceParser; + private readonly HtmlLocalLinkParser _localLinkParser; + private readonly RichTextEditorPastedImages _pastedImages; + + /// /// The constructor will setup the property editor based on the attribute if one is found /// - public RichTextPropertyEditor(ILogger logger) : base(logger) + public RichTextPropertyEditor(ILogger logger, IUmbracoContextAccessor umbracoContextAccessor, HtmlImageSourceParser imageSourceParser, HtmlLocalLinkParser localLinkParser, RichTextEditorPastedImages pastedImages) + : base(logger) { + _umbracoContextAccessor = umbracoContextAccessor; + _imageSourceParser = imageSourceParser; + _localLinkParser = localLinkParser; + _pastedImages = pastedImages; } /// /// Create a custom value editor /// /// - protected override IDataValueEditor CreateValueEditor() => new RichTextPropertyValueEditor(Attribute); + protected override IDataValueEditor CreateValueEditor() => new RichTextPropertyValueEditor(Attribute, _umbracoContextAccessor, _imageSourceParser, _localLinkParser, _pastedImages); protected override IConfigurationEditor CreateConfigurationEditor() => new RichTextConfigurationEditor(); @@ -38,11 +57,21 @@ namespace Umbraco.Web.PropertyEditors /// /// A custom value editor to ensure that macro syntax is parsed when being persisted and formatted correctly for display in the editor /// - internal class RichTextPropertyValueEditor : DataValueEditor + internal class RichTextPropertyValueEditor : DataValueEditor, IDataValueReference { - public RichTextPropertyValueEditor(DataEditorAttribute attribute) + private IUmbracoContextAccessor _umbracoContextAccessor; + private readonly HtmlImageSourceParser _imageSourceParser; + private readonly HtmlLocalLinkParser _localLinkParser; + private readonly RichTextEditorPastedImages _pastedImages; + + public RichTextPropertyValueEditor(DataEditorAttribute attribute, IUmbracoContextAccessor umbracoContextAccessor, HtmlImageSourceParser imageSourceParser, HtmlLocalLinkParser localLinkParser, RichTextEditorPastedImages pastedImages) : base(attribute) - { } + { + _umbracoContextAccessor = umbracoContextAccessor; + _imageSourceParser = imageSourceParser; + _localLinkParser = localLinkParser; + _pastedImages = pastedImages; + } /// public override object Configuration @@ -67,14 +96,13 @@ namespace Umbraco.Web.PropertyEditors /// /// /// - /// public override object ToEditor(Property property, IDataTypeService dataTypeService, string culture = null, string segment = null) { var val = property.GetValue(culture, segment); if (val == null) return null; - var propertyValueWithMediaResolved = TemplateUtilities.ResolveMediaFromTextString(val.ToString()); + var propertyValueWithMediaResolved = _imageSourceParser.EnsureImageSources(val.ToString()); var parsed = MacroTagParser.FormatRichTextPersistedDataForEditor(propertyValueWithMediaResolved, new Dictionary()); return parsed; } @@ -90,10 +118,36 @@ namespace Umbraco.Web.PropertyEditors if (editorValue.Value == null) return null; - var editorValueWithMediaUrlsRemoved = TemplateUtilities.RemoveMediaUrlsFromTextString(editorValue.Value.ToString()); + var userId = _umbracoContextAccessor.UmbracoContext?.Security?.CurrentUser?.Id ?? Constants.Security.SuperUserId; + + var config = editorValue.DataTypeConfiguration as RichTextConfiguration; + var mediaParent = config?.MediaParentId; + var mediaParentId = mediaParent == null ? Guid.Empty : mediaParent.Guid; + + var parseAndSavedTempImages = _pastedImages.FindAndPersistPastedTempImages(editorValue.Value.ToString(), mediaParentId, userId); + var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); var parsed = MacroTagParser.FormatRichTextContentForPersistence(editorValueWithMediaUrlsRemoved); + return parsed; } + + /// + /// Resolve references from values + /// + /// + /// + public IEnumerable GetReferences(object value) + { + var asString = value == null ? string.Empty : value is string str ? str : value.ToString(); + + foreach (var udi in _imageSourceParser.FindUdisFromDataAttributes(asString)) + yield return new UmbracoEntityReference(udi); + + foreach (var udi in _localLinkParser.FindUdisFromLocalLinks(asString)) + yield return new UmbracoEntityReference(udi); + + //TODO: Detect Macros too ... but we can save that for a later date, right now need to do media refs + } } internal class RichTextPropertyIndexValueFactory : IPropertyIndexValueFactory diff --git a/src/Umbraco.Web/PropertyEditors/SliderPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/SliderPropertyEditor.cs index c4a966cf49..8a98f15081 100644 --- a/src/Umbraco.Web/PropertyEditors/SliderPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/SliderPropertyEditor.cs @@ -7,7 +7,11 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a slider editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.Slider, "Slider", "slider", Icon = "icon-navigation-horizontal")] + [DataEditor( + Constants.PropertyEditors.Aliases.Slider, + "Slider", + "slider", + Icon = "icon-navigation-horizontal")] public class SliderPropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs index 578b6fcd00..fd7e8694a3 100644 --- a/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/TagsPropertyEditor.cs @@ -15,7 +15,11 @@ namespace Umbraco.Web.PropertyEditors /// Represents a tags property editor. /// [TagsPropertyEditor] - [DataEditor(Constants.PropertyEditors.Aliases.Tags, "Tags", "tags", Icon="icon-tags")] + [DataEditor( + Constants.PropertyEditors.Aliases.Tags, + "Tags", + "tags", + Icon = "icon-tags")] public class TagsPropertyEditor : DataEditor { private readonly ManifestValueValidatorCollection _validators; diff --git a/src/Umbraco.Web/PropertyEditors/TextAreaPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/TextAreaPropertyEditor.cs index f9ba99fd1e..c7bc2efbda 100644 --- a/src/Umbraco.Web/PropertyEditors/TextAreaPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/TextAreaPropertyEditor.cs @@ -7,7 +7,13 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a textarea property and parameter editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.TextArea, EditorType.PropertyValue | EditorType.MacroParameter, "Textarea", "textarea", ValueType = ValueTypes.Text, Icon="icon-application-window-alt")] + [DataEditor( + Constants.PropertyEditors.Aliases.TextArea, + EditorType.PropertyValue | EditorType.MacroParameter, + "Textarea", + "textarea", + ValueType = ValueTypes.Text, + Icon = "icon-application-window-alt")] public class TextAreaPropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/TextboxPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/TextboxPropertyEditor.cs index 4862f6c9aa..18f8155198 100644 --- a/src/Umbraco.Web/PropertyEditors/TextboxPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/TextboxPropertyEditor.cs @@ -7,7 +7,12 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a textbox property and parameter editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.TextBox, EditorType.PropertyValue | EditorType.MacroParameter, "Textbox", "textbox", Group = "Common")] + [DataEditor( + Constants.PropertyEditors.Aliases.TextBox, + EditorType.PropertyValue | EditorType.MacroParameter, + "Textbox", + "textbox", + Group = Constants.PropertyEditors.Groups.Common)] public class TextboxPropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/TrueFalsePropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/TrueFalsePropertyEditor.cs index ccd3bee744..fb2d2b29fe 100644 --- a/src/Umbraco.Web/PropertyEditors/TrueFalsePropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/TrueFalsePropertyEditor.cs @@ -7,7 +7,14 @@ namespace Umbraco.Web.PropertyEditors /// /// Represents a checkbox property and parameter editor. /// - [DataEditor(Constants.PropertyEditors.Aliases.Boolean, EditorType.PropertyValue | EditorType.MacroParameter, "Checkbox", "boolean", ValueType = ValueTypes.Integer, Group = "Common", Icon="icon-checkbox")] + [DataEditor( + Constants.PropertyEditors.Aliases.Boolean, + EditorType.PropertyValue | EditorType.MacroParameter, + "Checkbox", + "boolean", + ValueType = ValueTypes.Integer, + Group = Constants.PropertyEditors.Groups.Common, + Icon = "icon-checkbox")] public class TrueFalsePropertyEditor : DataEditor { /// diff --git a/src/Umbraco.Web/PropertyEditors/UserPickerPropertyEditor.cs b/src/Umbraco.Web/PropertyEditors/UserPickerPropertyEditor.cs index 1d3ab05e96..daf574719a 100644 --- a/src/Umbraco.Web/PropertyEditors/UserPickerPropertyEditor.cs +++ b/src/Umbraco.Web/PropertyEditors/UserPickerPropertyEditor.cs @@ -6,7 +6,13 @@ using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.PropertyEditors { - [DataEditor(Constants.PropertyEditors.Aliases.UserPicker, "User picker", "entitypicker", ValueType = ValueTypes.Integer, Group = "People", Icon = Constants.Icons.User)] + [DataEditor( + Constants.PropertyEditors.Aliases.UserPicker, + "User picker", + "entitypicker", + ValueType = ValueTypes.Integer, + Group = Constants.PropertyEditors.Groups.People, + Icon = Constants.Icons.User)] public class UserPickerPropertyEditor : DataEditor { public UserPickerPropertyEditor(ILogger logger) diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/MacroContainerValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/MacroContainerValueConverter.cs deleted file mode 100644 index ef356e3604..0000000000 --- a/src/Umbraco.Web/PropertyEditors/ValueConverters/MacroContainerValueConverter.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Web; -using Umbraco.Core; -using Umbraco.Core.Cache; -using Umbraco.Core.Macros; -using Umbraco.Core.Models.PublishedContent; -using Umbraco.Core.PropertyEditors; -using Umbraco.Core.Services; -using Umbraco.Web.Macros; - -namespace Umbraco.Web.PropertyEditors.ValueConverters -{ - /// - /// Ensures macro syntax is parsed for the macro container which will work when getting the field - /// values in any way (i.e. dynamically, using Field(), or IPublishedContent) - /// - [DefaultPropertyValueConverter] - public class MacroContainerValueConverter : PropertyValueConverterBase - { - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly IMacroRenderer _macroRenderer; - - public MacroContainerValueConverter(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer) - { - _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); - _macroRenderer = macroRenderer ?? throw new ArgumentNullException(nameof(macroRenderer)); - } - - public override bool IsConverter(IPublishedPropertyType propertyType) - => propertyType.EditorAlias == Constants.PropertyEditors.Aliases.MacroContainer; - - public override Type GetPropertyValueType(IPublishedPropertyType propertyType) - => typeof (IHtmlString); - - public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) - => PropertyCacheLevel.Snapshot; - - // NOT thread-safe over a request because it modifies the - // global UmbracoContext.Current.InPreviewMode status. So it - // should never execute in // over the same UmbracoContext with - // different preview modes. - string RenderMacros(string source, bool preview) - { - var umbracoContext = _umbracoContextAccessor.UmbracoContext; - using (umbracoContext.ForcedPreview(preview)) // force for macro rendering - { - var sb = new StringBuilder(); - - MacroTagParser.ParseMacros( - source, - //callback for when text block is found - textBlock => sb.Append(textBlock), - //callback for when macro syntax is found - (macroAlias, macroAttributes) => sb.Append(_macroRenderer.Render( - macroAlias, - umbracoContext.PublishedRequest?.PublishedContent, - //needs to be explicitly casted to Dictionary - macroAttributes.ConvertTo(x => (string)x, x => x)).ToString())); - - return sb.ToString(); - } - } - - public override object ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object source, bool preview) - { - if (source == null) return null; - var sourceString = source.ToString(); - - // ensure string is parsed for macros and macros are executed correctly - sourceString = RenderMacros(sourceString, preview); - - return new HtmlString(sourceString); - } - } -} diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs index e11f3e0d3a..c62a79d283 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs @@ -12,6 +12,15 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters [DefaultPropertyValueConverter] public class MarkdownEditorValueConverter : PropertyValueConverterBase { + private readonly HtmlLocalLinkParser _localLinkParser; + private readonly HtmlUrlParser _urlParser; + + public MarkdownEditorValueConverter(HtmlLocalLinkParser localLinkParser, HtmlUrlParser urlParser) + { + _localLinkParser = localLinkParser; + _urlParser = urlParser; + } + public override bool IsConverter(IPublishedPropertyType propertyType) => Constants.PropertyEditors.Aliases.MarkdownEditor == propertyType.EditorAlias; @@ -27,8 +36,8 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters var sourceString = source.ToString(); // ensures string is parsed for {localLink} and urls are resolved correctly - sourceString = TemplateUtilities.ParseInternalLinks(sourceString, preview, Current.UmbracoContext); - sourceString = TemplateUtilities.ResolveUrlsFromTextString(sourceString); + sourceString = _localLinkParser.EnsureInternalLinks(sourceString, preview); + sourceString = _urlParser.EnsureUrls(sourceString); return sourceString; } diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs index d2272a25b5..a56630d7c5 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs @@ -37,14 +37,8 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters public override Type GetPropertyValueType(IPublishedPropertyType propertyType) { var isMultiple = IsMultipleDataType(propertyType.DataType); - var isOnlyImages = IsOnlyImagesDataType(propertyType.DataType); - return isMultiple - ? isOnlyImages - ? typeof(IEnumerable<>).MakeGenericType(ModelType.For(ImageTypeAlias)) - : typeof(IEnumerable) - : isOnlyImages - ? ModelType.For(ImageTypeAlias) + ? typeof(IEnumerable) : typeof(IPublishedContent); } @@ -57,12 +51,6 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters return config.Multiple; } - private bool IsOnlyImagesDataType(PublishedDataType dataType) - { - var config = ConfigurationEditor.ConfigurationAs(dataType.Configuration); - return config.OnlyImages; - } - public override object ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object source, bool preview) { @@ -79,12 +67,9 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters PropertyCacheLevel cacheLevel, object source, bool preview) { var isMultiple = IsMultipleDataType(propertyType.DataType); - var isOnlyImages = IsOnlyImagesDataType(propertyType.DataType); var udis = (Udi[]) source; - var mediaItems = isOnlyImages - ? _publishedModelFactory.CreateModelList(ImageTypeAlias) - : new List(); + var mediaItems = new List(); if (source == null) return isMultiple ? mediaItems : null; diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs index 559777786f..4a25049695 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentManyValueConverter.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json.Linq; using Umbraco.Core.Logging; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.PropertyEditors.ValueConverters; using Umbraco.Web.PublishedCache; namespace Umbraco.Web.PropertyEditors.ValueConverters @@ -14,6 +15,7 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters /// /// Provides an implementation for for nested content. /// + [DefaultPropertyValueConverter(typeof(JsonValueConverter))] public class NestedContentManyValueConverter : NestedContentValueConverterBase { private readonly IProfilingLogger _proflog; diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs index 06aa0b42fb..c9c99615f6 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/NestedContentSingleValueConverter.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json.Linq; using Umbraco.Core.Logging; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.PropertyEditors; +using Umbraco.Core.PropertyEditors.ValueConverters; using Umbraco.Web.PublishedCache; namespace Umbraco.Web.PropertyEditors.ValueConverters @@ -13,6 +14,7 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters /// /// Provides an implementation for for nested content. /// + [DefaultPropertyValueConverter(typeof(JsonValueConverter))] public class NestedContentSingleValueConverter : NestedContentValueConverterBase { private readonly IProfilingLogger _proflog; diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs index cb6ce6dd6d..3ab502742c 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs @@ -11,6 +11,7 @@ using Umbraco.Core.Cache; using Umbraco.Core.Services; using Umbraco.Web.Composing; using Umbraco.Web.Macros; +using System.Web; namespace Umbraco.Web.PropertyEditors.ValueConverters { @@ -23,6 +24,9 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters { private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly IMacroRenderer _macroRenderer; + private readonly HtmlLocalLinkParser _linkParser; + private readonly HtmlUrlParser _urlParser; + private readonly HtmlImageSourceParser _imageSourceParser; public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) { @@ -31,10 +35,14 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters return PropertyCacheLevel.Snapshot; } - public RteMacroRenderingValueConverter(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer) + public RteMacroRenderingValueConverter(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer, + HtmlLocalLinkParser linkParser, HtmlUrlParser urlParser, HtmlImageSourceParser imageSourceParser) { _umbracoContextAccessor = umbracoContextAccessor; _macroRenderer = macroRenderer; + _linkParser = linkParser; + _urlParser = urlParser; + _imageSourceParser = imageSourceParser; } // NOT thread-safe over a request because it modifies the @@ -63,7 +71,14 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters } } - public override object ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object source, bool preview) + public override object ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object inter, bool preview) + { + var converted = Convert(inter, preview); + + return new HtmlString(converted == null ? string.Empty : converted); + } + + private string Convert(object source, bool preview) { if (source == null) { @@ -73,9 +88,9 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters var sourceString = source.ToString(); // ensures string is parsed for {localLink} and urls and media are resolved correctly - sourceString = TemplateUtilities.ParseInternalLinks(sourceString, preview, Current.UmbracoContext); - sourceString = TemplateUtilities.ResolveUrlsFromTextString(sourceString); - sourceString = TemplateUtilities.ResolveMediaFromTextString(sourceString); + sourceString = _linkParser.EnsureInternalLinks(sourceString, preview); + sourceString = _urlParser.EnsureUrls(sourceString); + sourceString = _imageSourceParser.EnsureImageSources(sourceString); // ensure string is parsed for macros and macros are executed correctly sourceString = RenderRteMacros(sourceString, preview); diff --git a/src/Umbraco.Web/PropertyEditors/ValueConverters/TextStringValueConverter.cs b/src/Umbraco.Web/PropertyEditors/ValueConverters/TextStringValueConverter.cs index b8ad1477b4..939a658407 100644 --- a/src/Umbraco.Web/PropertyEditors/ValueConverters/TextStringValueConverter.cs +++ b/src/Umbraco.Web/PropertyEditors/ValueConverters/TextStringValueConverter.cs @@ -11,11 +11,19 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters [DefaultPropertyValueConverter] public class TextStringValueConverter : PropertyValueConverterBase { + public TextStringValueConverter(HtmlLocalLinkParser linkParser, HtmlUrlParser urlParser) + { + _linkParser = linkParser; + _urlParser = urlParser; + } + private static readonly string[] PropertyTypeAliases = { Constants.PropertyEditors.Aliases.TextBox, Constants.PropertyEditors.Aliases.TextArea }; + private readonly HtmlLocalLinkParser _linkParser; + private readonly HtmlUrlParser _urlParser; public override bool IsConverter(IPublishedPropertyType propertyType) => PropertyTypeAliases.Contains(propertyType.EditorAlias); @@ -32,8 +40,8 @@ namespace Umbraco.Web.PropertyEditors.ValueConverters var sourceString = source.ToString(); // ensures string is parsed for {localLink} and urls are resolved correctly - sourceString = TemplateUtilities.ParseInternalLinks(sourceString, preview, Current.UmbracoContext); - sourceString = TemplateUtilities.ResolveUrlsFromTextString(sourceString); + sourceString = _linkParser.EnsureInternalLinks(sourceString, preview); + sourceString = _urlParser.EnsureUrls(sourceString); return sourceString; } diff --git a/src/Umbraco.Web/PublishedCache/NuCache/ContentNode.cs b/src/Umbraco.Web/PublishedCache/NuCache/ContentNode.cs index 1e8d5ddfc9..a724e78b72 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/ContentNode.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/ContentNode.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using Umbraco.Core.Models.PublishedContent; using Umbraco.Web.PublishedCache.NuCache.DataSource; @@ -6,13 +7,16 @@ namespace Umbraco.Web.PublishedCache.NuCache { // represents a content "node" ie a pair of draft + published versions // internal, never exposed, to be accessed from ContentStore (only!) + [DebuggerDisplay("Id: {Id}, Path: {Path}")] internal class ContentNode { // special ctor for root pseudo node public ContentNode() { FirstChildContentId = -1; + LastChildContentId = -1; NextSiblingContentId = -1; + PreviousSiblingContentId = -1; } // special ctor with no content data - for members @@ -58,7 +62,9 @@ namespace Umbraco.Web.PublishedCache.NuCache SortOrder = sortOrder; ParentContentId = parentContentId; FirstChildContentId = -1; + LastChildContentId = -1; NextSiblingContentId = -1; + PreviousSiblingContentId = -1; CreateDate = createDate; CreatorId = creatorId; } @@ -71,17 +77,11 @@ namespace Umbraco.Web.PublishedCache.NuCache if (draftData == null && publishedData == null) throw new ArgumentException("Both draftData and publishedData cannot be null at the same time."); - if (draftData != null) - { - DraftContent = new PublishedContent(this, draftData, publishedSnapshotAccessor, variationContextAccessor); - DraftModel = DraftContent.CreateModel(); - } + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _variationContextAccessor = variationContextAccessor; - if (publishedData != null) - { - PublishedContent = new PublishedContent(this, publishedData, publishedSnapshotAccessor, variationContextAccessor); - PublishedModel = PublishedContent.CreateModel(); - } + _draftData = draftData; + _publishedData = publishedData; } // clone @@ -95,22 +95,22 @@ namespace Umbraco.Web.PublishedCache.NuCache SortOrder = origin.SortOrder; ParentContentId = origin.ParentContentId; FirstChildContentId = origin.FirstChildContentId; + LastChildContentId = origin.LastChildContentId; NextSiblingContentId = origin.NextSiblingContentId; + PreviousSiblingContentId = origin.PreviousSiblingContentId; CreateDate = origin.CreateDate; CreatorId = origin.CreatorId; - var originDraft = origin.DraftContent; - var originPublished = origin.PublishedContent; - - DraftContent = originDraft == null ? null : new PublishedContent(this, originDraft); - PublishedContent = originPublished == null ? null : new PublishedContent(this, originPublished); - - DraftModel = DraftContent?.CreateModel(); - PublishedModel = PublishedContent?.CreateModel(); + _draftData = origin._draftData; + _publishedData = origin._publishedData; + _publishedSnapshotAccessor = origin._publishedSnapshotAccessor; + _variationContextAccessor = origin._variationContextAccessor; } // everything that is common to both draft and published versions // keep this as small as possible + + public readonly int Id; public readonly Guid Uid; public IPublishedContentType ContentType; @@ -118,20 +118,52 @@ namespace Umbraco.Web.PublishedCache.NuCache public readonly string Path; public readonly int SortOrder; public readonly int ParentContentId; + + // TODO: Can we make everything readonly?? This would make it easier to debug and be less error prone especially for new developers. + // Once a Node is created and exists in the cache it is readonly so we should be able to make that happen at the API level too. public int FirstChildContentId; + public int LastChildContentId; public int NextSiblingContentId; + public int PreviousSiblingContentId; + public readonly DateTime CreateDate; public readonly int CreatorId; - // draft and published version (either can be null, but not both) - // are the direct PublishedContent instances - public PublishedContent DraftContent; - public PublishedContent PublishedContent; + private ContentData _draftData; + private ContentData _publishedData; + private IVariationContextAccessor _variationContextAccessor; + private IPublishedSnapshotAccessor _publishedSnapshotAccessor; + + public bool HasPublished => _publishedData != null; + public bool HasPublishedCulture(string culture) => _publishedData != null && _publishedData.CultureInfos.ContainsKey(culture); // draft and published version (either can be null, but not both) // are models not direct PublishedContent instances - public IPublishedContent DraftModel; - public IPublishedContent PublishedModel; + private IPublishedContent _draftModel; + private IPublishedContent _publishedModel; + + private IPublishedContent GetModel(ref IPublishedContent model, ContentData contentData) + { + if (model != null) return model; + if (contentData == null) return null; + + // create the model - we want to be fast, so no lock here: we may create + // more than 1 instance, but the lock below ensures we only ever return + // 1 unique instance - and locking is a nice explicit way to ensure this + + var m = new PublishedContent(this, contentData, _publishedSnapshotAccessor, _variationContextAccessor).CreateModel(); + + // locking 'this' is not a best-practice but ContentNode is internal and + // we know what we do, so it is fine here and avoids allocating an object + lock (this) + { + return model = model ?? m; + } + } + + public IPublishedContent DraftModel => GetModel(ref _draftModel, _draftData); + + public IPublishedContent PublishedModel => GetModel(ref _publishedModel, _publishedData); public ContentNodeKit ToKit() => new ContentNodeKit @@ -139,8 +171,8 @@ namespace Umbraco.Web.PublishedCache.NuCache Node = this, ContentTypeId = ContentType.Id, - DraftData = DraftContent?.ContentData, - PublishedData = PublishedContent?.ContentData + DraftData = _draftData, + PublishedData = _publishedData }; } } diff --git a/src/Umbraco.Web/PublishedCache/NuCache/ContentStore.cs b/src/Umbraco.Web/PublishedCache/NuCache/ContentStore.cs index 2d501fa3b5..f92d8adebb 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/ContentStore.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/ContentStore.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using CSharpTest.Net.Collections; using Umbraco.Core; +using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.Scoping; @@ -13,12 +14,24 @@ using Umbraco.Web.PublishedCache.NuCache.Snap; namespace Umbraco.Web.PublishedCache.NuCache { - // stores content + /// + /// Stores content in memory and persists it back to disk + /// + /// + /// + /// Methods in this class suffixed with the term "Locked" means that those methods can only be called within a WriteLock. A WriteLock + /// is acquired by the GetScopedWriteLock method. Locks are not allowed to be recursive. + /// + /// + /// This class's logic is based on the class but has been slightly modified to suit these purposes. + /// + /// internal class ContentStore { // this class is an extended version of SnapDictionary // most of the snapshots management code, etc is an exact copy // SnapDictionary has unit tests to ensure it all works correctly + // For locking information, see SnapDictionary private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IVariationContextAccessor _variationContextAccessor; @@ -37,7 +50,6 @@ namespace Umbraco.Web.PublishedCache.NuCache private long _liveGen, _floorGen; private bool _nextGen, _collectAuto; private Task _collectTask; - private volatile int _wlocked; private List> _wchanges; // TODO: collection trigger (ok for now) @@ -78,15 +90,9 @@ namespace Umbraco.Web.PublishedCache.NuCache private readonly string _instanceId = Guid.NewGuid().ToString("N"); - private class ReadLockInfo - { - public bool Taken; - } - private class WriteLockInfo { public bool Taken; - public bool Count; } // a scope contextual that represents a locked writer to the dictionary @@ -117,21 +123,26 @@ namespace Umbraco.Web.PublishedCache.NuCache return ScopeContextualBase.Get(scopeProvider, _instanceId, scoped => new ScopedWriteLock(this, scoped)); } + private void EnsureLocked() + { + if (!Monitor.IsEntered(_wlocko)) + throw new InvalidOperationException("Write lock must be acquried."); + } + private void Lock(WriteLockInfo lockInfo, bool forceGen = false) { + if (Monitor.IsEntered(_wlocko)) + throw new InvalidOperationException("Recursive locks not allowed"); + Monitor.Enter(_wlocko, ref lockInfo.Taken); - var rtaken = false; - try + lock(_rlocko) { - Monitor.Enter(_rlocko, ref rtaken); - // see SnapDictionary - try { } finally + try { } + finally { - _wlocked++; - lockInfo.Count = true; - if (_nextGen == false || (forceGen && _wlocked == 1)) + if (_nextGen == false || (forceGen)) { // because we are changing things, a new generation // is created, which will trigger a new snapshot @@ -141,63 +152,49 @@ namespace Umbraco.Web.PublishedCache.NuCache _nextGen = true; } } - } - finally - { - if (rtaken) Monitor.Exit(_rlocko); - } - } - - private void Lock(ReadLockInfo lockInfo) - { - Monitor.Enter(_rlocko, ref lockInfo.Taken); + } } private void Release(WriteLockInfo lockInfo, bool commit = true) { - if (commit == false) + try { - var rtaken = false; - try + if (commit == false) { - Monitor.Enter(_rlocko, ref rtaken); - try { } - finally + lock (_rlocko) { - _nextGen = false; - _liveGen -= 1; + // see SnapDictionary + try { } + finally + { + _nextGen = false; + _liveGen -= 1; + } } - } - finally - { - if (rtaken) Monitor.Exit(_rlocko); - } - Rollback(_contentNodes); - RollbackRoot(); - Rollback(_contentTypesById); - Rollback(_contentTypesByAlias); + Rollback(_contentNodes); + RollbackRoot(); + Rollback(_contentTypesById); + Rollback(_contentTypesByAlias); + } + else if (_localDb != null && _wchanges != null) + { + foreach (var change in _wchanges) + { + if (change.Value.IsNull) + _localDb.TryRemove(change.Key, out ContentNodeKit unused); + else + _localDb[change.Key] = change.Value; + } + _wchanges = null; + _localDb.Commit(); + } } - else if (_localDb != null && _wchanges != null) + finally { - foreach (var change in _wchanges) - { - if (change.Value.IsNull) - _localDb.TryRemove(change.Key, out ContentNodeKit unused); - else - _localDb[change.Key] = change.Value; - } - _wchanges = null; - _localDb.Commit(); + if (lockInfo.Taken) + Monitor.Exit(_wlocko); } - - if (lockInfo.Count) _wlocked--; - if (lockInfo.Taken) Monitor.Exit(_wlocko); - } - - private void Release(ReadLockInfo lockInfo) - { - if (lockInfo.Taken) Monitor.Exit(_rlocko); } private void RollbackRoot() @@ -233,11 +230,33 @@ namespace Umbraco.Web.PublishedCache.NuCache var lockInfo = new WriteLockInfo(); try { - Lock(lockInfo); + try + { + // Trying to lock could throw exceptions so always make sure to clean up. + Lock(lockInfo); + } + finally + { + try + { + _localDb?.Dispose(); + } + catch (Exception ex) + { + /* TBD: May already be throwing so don't throw again */ + _logger.Error(ex, "Error trying to release DB"); + } + finally + { + _localDb = null; + } + } - if (_localDb == null) return; - _localDb.Dispose(); - _localDb = null; + } + catch (Exception ex) + { + _logger.Error(ex, "Error trying to lock"); + throw; } finally { @@ -255,87 +274,111 @@ namespace Umbraco.Web.PublishedCache.NuCache #region Content types - public void NewContentTypes(IEnumerable types) + /// + /// Sets data for new content types + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void NewContentTypesLocked(IEnumerable types) { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); + EnsureLocked(); - foreach (var type in types) - { - SetValueLocked(_contentTypesById, type.Id, type); - SetValueLocked(_contentTypesByAlias, type.Alias, type); - } - } - finally + foreach (var type in types) { - Release(lockInfo); + SetValueLocked(_contentTypesById, type.Id, type); + SetValueLocked(_contentTypesByAlias, type.Alias, type); } } - public void UpdateContentTypes(IEnumerable types) + /// + /// Sets data for updated content types + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void UpdateContentTypesLocked(IEnumerable types) { - //nothing to do if this is empty, no need to lock/allocate/iterate/etc... - if (!types.Any()) return; + //nothing to do if this is empty, no need to lock/allocate/iterate/etc... + if (!types.Any()) return; - var lockInfo = new WriteLockInfo(); - try + EnsureLocked(); + + var index = types.ToDictionary(x => x.Id, x => x); + + foreach (var type in index.Values) { - Lock(lockInfo); - - var index = types.ToDictionary(x => x.Id, x => x); - - foreach (var type in index.Values) - { - SetValueLocked(_contentTypesById, type.Id, type); - SetValueLocked(_contentTypesByAlias, type.Alias, type); - } - - foreach (var link in _contentNodes.Values) - { - var node = link.Value; - if (node == null) continue; - var contentTypeId = node.ContentType.Id; - if (index.TryGetValue(contentTypeId, out var contentType) == false) continue; - SetValueLocked(_contentNodes, node.Id, new ContentNode(node, contentType)); - } + SetValueLocked(_contentTypesById, type.Id, type); + SetValueLocked(_contentTypesByAlias, type.Alias, type); } - finally + + foreach (var link in _contentNodes.Values) { - Release(lockInfo); + var node = link.Value; + if (node == null) continue; + var contentTypeId = node.ContentType.Id; + if (index.TryGetValue(contentTypeId, out var contentType) == false) continue; + SetValueLocked(_contentNodes, node.Id, new ContentNode(node, contentType)); } } - public void SetAllContentTypes(IEnumerable types) + /// + /// Updates/sets data for all content types + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void SetAllContentTypesLocked(IEnumerable types) { - var lockInfo = new WriteLockInfo(); - try + EnsureLocked(); + + // clear all existing content types + ClearLocked(_contentTypesById); + ClearLocked(_contentTypesByAlias); + + // set all new content types + foreach (var type in types) { - Lock(lockInfo); - - // clear all existing content types - ClearLocked(_contentTypesById); - ClearLocked(_contentTypesByAlias); - - // set all new content types - foreach (var type in types) - { - SetValueLocked(_contentTypesById, type.Id, type); - SetValueLocked(_contentTypesByAlias, type.Alias, type); - } - - // beware! at that point the cache is inconsistent, - // assuming we are going to SetAll content items! - } - finally - { - Release(lockInfo); + SetValueLocked(_contentTypesById, type.Id, type); + SetValueLocked(_contentTypesByAlias, type.Alias, type); } + + // beware! at that point the cache is inconsistent, + // assuming we are going to SetAll content items! } - public void UpdateContentTypes(IReadOnlyCollection removedIds, IReadOnlyCollection refreshedTypes, IReadOnlyCollection kits) + /// + /// Updates/sets/removes data for content types + /// + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void UpdateContentTypesLocked(IReadOnlyCollection removedIds, IReadOnlyCollection refreshedTypes, IReadOnlyCollection kits) { + EnsureLocked(); + var removedIdsA = removedIds ?? Array.Empty(); var refreshedTypesA = refreshedTypes ?? Array.Empty(); var refreshedIdsA = refreshedTypesA.Select(x => x.Id).ToList(); @@ -344,85 +387,84 @@ namespace Umbraco.Web.PublishedCache.NuCache if (kits.Count == 0 && refreshedIdsA.Count == 0 && removedIdsA.Count == 0) return; //exit - there is nothing to do here - var lockInfo = new WriteLockInfo(); - try + var removedContentTypeNodes = new List(); + var refreshedContentTypeNodes = new List(); + + // find all the nodes that are either refreshed or removed, + // because of their content type being either refreshed or removed + foreach (var link in _contentNodes.Values) { - Lock(lockInfo); - - var removedContentTypeNodes = new List(); - var refreshedContentTypeNodes = new List(); - - // find all the nodes that are either refreshed or removed, - // because of their content type being either refreshed or removed - foreach (var link in _contentNodes.Values) - { - var node = link.Value; - if (node == null) continue; - var contentTypeId = node.ContentType.Id; - if (removedIdsA.Contains(contentTypeId)) removedContentTypeNodes.Add(node.Id); - if (refreshedIdsA.Contains(contentTypeId)) refreshedContentTypeNodes.Add(node.Id); - } - - // perform deletion of content with removed content type - // removing content types should have removed their content already - // but just to be 100% sure, clear again here - foreach (var node in removedContentTypeNodes) - ClearBranchLocked(node); - - // perform deletion of removed content types - foreach (var id in removedIdsA) - { - if (_contentTypesById.TryGetValue(id, out var link) == false || link.Value == null) - continue; - SetValueLocked(_contentTypesById, id, null); - SetValueLocked(_contentTypesByAlias, link.Value.Alias, null); - } - - // perform update of refreshed content types - foreach (var type in refreshedTypesA) - { - SetValueLocked(_contentTypesById, type.Id, type); - SetValueLocked(_contentTypesByAlias, type.Alias, type); - } - - // perform update of content with refreshed content type - from the kits - // skip missing type, skip missing parents & un-buildable kits - what else could we do? - // kits are ordered by level, so ParentExists is ok here - var visited = new List(); - foreach (var kit in kits.Where(x => - refreshedIdsA.Contains(x.ContentTypeId) && - BuildKit(x))) - { - // replacing the node: must preserve the parents - var node = GetHead(_contentNodes, kit.Node.Id)?.Value; - if (node != null) - kit.Node.FirstChildContentId = node.FirstChildContentId; - - SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - - visited.Add(kit.Node.Id); - if (_localDb != null) RegisterChange(kit.Node.Id, kit); - } - - // all content should have been refreshed - but... - var orphans = refreshedContentTypeNodes.Except(visited); - foreach (var id in orphans) - ClearBranchLocked(id); + var node = link.Value; + if (node == null) continue; + var contentTypeId = node.ContentType.Id; + if (removedIdsA.Contains(contentTypeId)) removedContentTypeNodes.Add(node.Id); + if (refreshedIdsA.Contains(contentTypeId)) refreshedContentTypeNodes.Add(node.Id); } - finally + + // perform deletion of content with removed content type + // removing content types should have removed their content already + // but just to be 100% sure, clear again here + foreach (var node in removedContentTypeNodes) + ClearBranchLocked(node); + + // perform deletion of removed content types + foreach (var id in removedIdsA) { - Release(lockInfo); + if (_contentTypesById.TryGetValue(id, out var link) == false || link.Value == null) + continue; + SetValueLocked(_contentTypesById, id, null); + SetValueLocked(_contentTypesByAlias, link.Value.Alias, null); } + + // perform update of refreshed content types + foreach (var type in refreshedTypesA) + { + SetValueLocked(_contentTypesById, type.Id, type); + SetValueLocked(_contentTypesByAlias, type.Alias, type); + } + + // perform update of content with refreshed content type - from the kits + // skip missing type, skip missing parents & un-buildable kits - what else could we do? + // kits are ordered by level, so ParentExists is ok here + var visited = new List(); + foreach (var kit in kits.Where(x => + refreshedIdsA.Contains(x.ContentTypeId) && + BuildKit(x, out _))) + { + // replacing the node: must preserve the parents + var node = GetHead(_contentNodes, kit.Node.Id)?.Value; + if (node != null) + kit.Node.FirstChildContentId = node.FirstChildContentId; + + SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); + + visited.Add(kit.Node.Id); + if (_localDb != null) RegisterChange(kit.Node.Id, kit); + } + + // all content should have been refreshed - but... + var orphans = refreshedContentTypeNodes.Except(visited); + foreach (var id in orphans) + ClearBranchLocked(id); } - public void UpdateDataTypes(IEnumerable dataTypeIds, Func getContentType) + /// + /// Updates data types + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public void UpdateDataTypesLocked(IEnumerable dataTypeIds, Func getContentType) { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); + EnsureLocked(); - var contentTypes = _contentTypesById + var contentTypes = _contentTypesById .Where(kvp => kvp.Value.Value != null && kvp.Value.Value.PropertyTypes.Any(p => dataTypeIds.Contains(p.DataType.Id))) @@ -431,44 +473,40 @@ namespace Umbraco.Web.PublishedCache.NuCache .Where(x => x != null) // poof, gone, very unlikely and probably an anomaly .ToArray(); - var contentTypeIdsA = contentTypes.Select(x => x.Id).ToArray(); - var contentTypeNodes = new Dictionary>(); - foreach (var id in contentTypeIdsA) - contentTypeNodes[id] = new List(); - foreach (var link in _contentNodes.Values) - { - var node = link.Value; - if (node != null && contentTypeIdsA.Contains(node.ContentType.Id)) - contentTypeNodes[node.ContentType.Id].Add(node.Id); - } - - foreach (var contentType in contentTypes) - { - // again, weird situation - if (contentTypeNodes.ContainsKey(contentType.Id) == false) - continue; - - foreach (var id in contentTypeNodes[contentType.Id]) - { - _contentNodes.TryGetValue(id, out var link); - if (link?.Value == null) - continue; - var node = new ContentNode(link.Value, contentType); - SetValueLocked(_contentNodes, id, node); - if (_localDb != null) RegisterChange(id, node.ToKit()); - } - } - } - finally + var contentTypeIdsA = contentTypes.Select(x => x.Id).ToArray(); + var contentTypeNodes = new Dictionary>(); + foreach (var id in contentTypeIdsA) + contentTypeNodes[id] = new List(); + foreach (var link in _contentNodes.Values) { - Release(lockInfo); + var node = link.Value; + if (node != null && contentTypeIdsA.Contains(node.ContentType.Id)) + contentTypeNodes[node.ContentType.Id].Add(node.Id); + } + + foreach (var contentType in contentTypes) + { + // again, weird situation + if (contentTypeNodes.ContainsKey(contentType.Id) == false) + continue; + + foreach (var id in contentTypeNodes[contentType.Id]) + { + _contentNodes.TryGetValue(id, out var link); + if (link?.Value == null) + continue; + var node = new ContentNode(link.Value, contentType); + SetValueLocked(_contentNodes, id, node); + if (_localDb != null) RegisterChange(id, node.ToKit()); + } } } - private bool BuildKit(ContentNodeKit kit) + private bool BuildKit(ContentNodeKit kit, out LinkedNode parent) { // make sure parent exists - if (!ParentExistsLocked(kit)) + parent = GetParentLink(kit.Node, null); + if (parent == null) { _logger.Warn($"Skip item id={kit.Node.Id}, could not find parent id={kit.Node.ParentContentId}."); return false; @@ -503,6 +541,14 @@ namespace Umbraco.Web.PublishedCache.NuCache public int Count => _contentNodes.Count; + /// + /// Get the most recent version of the LinkedNode stored in the dictionary for the supplied key + /// + /// + /// + /// + /// + /// private static LinkedNode GetHead(ConcurrentDictionary> dict, TKey key) where TValue : class { @@ -510,8 +556,22 @@ namespace Umbraco.Web.PublishedCache.NuCache return link; } - public bool Set(ContentNodeKit kit) + /// + /// Sets the data for a + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool SetLocked(ContentNodeKit kit) { + EnsureLocked(); + // ReSharper disable LocalizableElement if (kit.IsEmpty) throw new ArgumentException("Kit is empty.", nameof(kit)); @@ -521,176 +581,278 @@ namespace Umbraco.Web.PublishedCache.NuCache _logger.Debug("Set content ID: {KitNodeId}", kit.Node.Id); - var lockInfo = new WriteLockInfo(); - try + // get existing + _contentNodes.TryGetValue(kit.Node.Id, out var link); + var existing = link?.Value; + + if (!BuildKit(kit, out var parent)) + return false; + + // moving? + var moving = existing != null && existing.ParentContentId != kit.Node.ParentContentId; + + // manage children + if (existing != null) { - Lock(lockInfo); - - // get existing - _contentNodes.TryGetValue(kit.Node.Id, out var link); - var existing = link?.Value; - - if (!BuildKit(kit)) - return false; - - // moving? - var moving = existing != null && existing.ParentContentId != kit.Node.ParentContentId; - - // manage children - if (existing != null) - kit.Node.FirstChildContentId = existing.FirstChildContentId; - - // set - SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - if (_localDb != null) RegisterChange(kit.Node.Id, kit); - - // manage the tree - if (existing == null) - { - // new, add to parent - AddNodeLocked(kit.Node); - } - else if (moving || existing.SortOrder != kit.Node.SortOrder) - { - // moved, remove existing from its parent, add content to its parent - RemoveNodeLocked(existing); - AddNodeLocked(kit.Node); - } - else - { - // replacing existing, handle siblings - kit.Node.NextSiblingContentId = existing.NextSiblingContentId; - } - - _xmap[kit.Node.Uid] = kit.Node.Id; + kit.Node.FirstChildContentId = existing.FirstChildContentId; + kit.Node.LastChildContentId = existing.LastChildContentId; } - finally + + // set + SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); + if (_localDb != null) RegisterChange(kit.Node.Id, kit); + + // manage the tree + if (existing == null) { - Release(lockInfo); + // new, add to parent + AddTreeNodeLocked(kit.Node, parent); } + else if (moving || existing.SortOrder != kit.Node.SortOrder) + { + // moved, remove existing from its parent, add content to its parent + RemoveTreeNodeLocked(existing); + AddTreeNodeLocked(kit.Node); + } + else + { + // replacing existing, handle siblings + kit.Node.NextSiblingContentId = existing.NextSiblingContentId; + kit.Node.PreviousSiblingContentId = existing.PreviousSiblingContentId; + } + + _xmap[kit.Node.Uid] = kit.Node.Id; return true; } private void ClearRootLocked() { - if (_root.Gen < _liveGen) + if (_root.Gen != _liveGen) _root = new LinkedNode(new ContentNode(), _liveGen, _root); else _root.Value.FirstChildContentId = -1; } - // IMPORTANT kits must be sorted out by LEVEL - public bool SetAll(IEnumerable kits, bool fromLocalDb = false) + /// + /// Builds all kits on startup using a fast forward only cursor + /// + /// + /// All kits sorted by Level + Parent Id + Sort order + /// + /// True if the data is coming from the database (not the local cache db) + /// + /// + /// + /// This requires that the collection is sorted by Level + ParentId + Sort Order. + /// This should be used only on a site startup as the first generations. + /// This CANNOT be used after startup since it bypasses all checks for Generations. + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool SetAllFastSortedLocked(IEnumerable kits, bool fromDb) { - var lockInfo = new WriteLockInfo(); + EnsureLocked(); + var ok = true; - try + + ClearLocked(_contentNodes); + ClearRootLocked(); + + // The name of the game here is to populate each kit's + // FirstChildContentId + // LastChildContentId + // NextSiblingContentId + // PreviousSiblingContentId + + ContentNode previousNode = null; + ContentNode parent = null; + + foreach (var kit in kits) { - Lock(lockInfo); - - ClearLocked(_contentNodes); - ClearRootLocked(); - - // do NOT clear types else they are gone! - //ClearLocked(_contentTypesById); - //ClearLocked(_contentTypesByAlias); - - foreach (var kit in kits) + if (!BuildKit(kit, out var parentLink)) { - if (!BuildKit(kit)) - { - ok = false; - continue; // skip that one - } - _logger.Debug($"Set {kit.Node.Id} with parent {kit.Node.ParentContentId}"); - SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - - // don't refresh _localDb if we are reading from _localDb - if (!fromLocalDb && _localDb != null) RegisterChange(kit.Node.Id, kit); - AddNodeLocked(kit.Node); - - _xmap[kit.Node.Uid] = kit.Node.Id; + ok = false; + continue; // skip that one } - } - finally - { - Release(lockInfo); + + var thisNode = kit.Node; + + if (parent == null) + { + // first parent + parent = parentLink.Value; + parent.FirstChildContentId = thisNode.Id; // this node is the first node + } + else if (parent.Id != parentLink.Value.Id) + { + // new parent + parent = parentLink.Value; + parent.FirstChildContentId = thisNode.Id; // this node is the first node + previousNode = null; // there is no previous sibling + } + + _logger.Debug($"Set {thisNode.Id} with parent {thisNode.ParentContentId}"); + SetValueLocked(_contentNodes, thisNode.Id, thisNode); + + // if we are initializing from the database source ensure the local db is updated + if (fromDb && _localDb != null) RegisterChange(thisNode.Id, kit); + + // this node is always the last child + parent.LastChildContentId = thisNode.Id; + + // wire previous node as previous sibling + if (previousNode != null) + { + previousNode.NextSiblingContentId = thisNode.Id; + thisNode.PreviousSiblingContentId = previousNode.Id; + } + + // this node becomes the previous node + previousNode = thisNode; + + _xmap[kit.Node.Uid] = kit.Node.Id; } return ok; } - // IMPORTANT kits must be sorted out by LEVEL and by SORT ORDER - public bool SetBranch(int rootContentId, IEnumerable kits) + /// + /// Set all data for a collection of + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool SetAllLocked(IEnumerable kits) { - var lockInfo = new WriteLockInfo(); + EnsureLocked(); + var ok = true; - try + + ClearLocked(_contentNodes); + ClearRootLocked(); + + // do NOT clear types else they are gone! + //ClearLocked(_contentTypesById); + //ClearLocked(_contentTypesByAlias); + + foreach (var kit in kits) { - Lock(lockInfo); - - // get existing - _contentNodes.TryGetValue(rootContentId, out var link); - var existing = link?.Value; - - // clear - if (existing != null) + if (!BuildKit(kit, out var parent)) { - ClearBranchLocked(existing); - RemoveNodeLocked(existing); + ok = false; + continue; // skip that one } + _logger.Debug($"Set {kit.Node.Id} with parent {kit.Node.ParentContentId}"); + SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - // now add them all back - foreach (var kit in kits) - { - if (!BuildKit(kit)) - { - ok = false; - continue; // skip that one - } - SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); - if (_localDb != null) RegisterChange(kit.Node.Id, kit); - AddNodeLocked(kit.Node); + if (_localDb != null) RegisterChange(kit.Node.Id, kit); + AddTreeNodeLocked(kit.Node, parent); - _xmap[kit.Node.Uid] = kit.Node.Id; - } - } - finally - { - Release(lockInfo); + _xmap[kit.Node.Uid] = kit.Node.Id; } return ok; } - public bool Clear(int id) + /// + /// Sets data for a branch of + /// + /// + /// + /// + /// + /// + /// IMPORTANT kits must be sorted out by LEVEL and by SORT ORDER + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool SetBranchLocked(int rootContentId, IEnumerable kits) { - var lockInfo = new WriteLockInfo(); - try + EnsureLocked(); + + var ok = true; + + // get existing + _contentNodes.TryGetValue(rootContentId, out var link); + var existing = link?.Value; + + // clear + if (existing != null) { - Lock(lockInfo); - - // try to find the content - // if it is not there, nothing to do - _contentNodes.TryGetValue(id, out var link); // else null - if (link?.Value == null) return false; - - var content = link.Value; - _logger.Debug("Clear content ID: {ContentId}", content.Id); - - // clear the entire branch - ClearBranchLocked(content); - - // manage the tree - RemoveNodeLocked(content); - - return true; + //this zero's out the branch (recursively), if we're in a new gen this will add a NULL placeholder for the gen + ClearBranchLocked(existing); + //TODO: This removes the current GEN from the tree - do we really want to do that? + RemoveTreeNodeLocked(existing); } - finally + + // now add them all back + foreach (var kit in kits) { - Release(lockInfo); + if (!BuildKit(kit, out var parent)) + { + ok = false; + continue; // skip that one + } + SetValueLocked(_contentNodes, kit.Node.Id, kit.Node); + if (_localDb != null) RegisterChange(kit.Node.Id, kit); + AddTreeNodeLocked(kit.Node, parent); + + _xmap[kit.Node.Uid] = kit.Node.Id; } + + return ok; + } + + /// + /// Clears data for a given node id + /// + /// + /// + /// + /// This methods MUST be called from within a write lock, normally wrapped within GetScopedWriteLock + /// otherwise an exception will occur. + /// + /// + /// Thrown if this method is not called within a write lock + /// + public bool ClearLocked(int id) + { + EnsureLocked(); + + // try to find the content + // if it is not there, nothing to do + _contentNodes.TryGetValue(id, out var link); // else null + if (link?.Value == null) return false; + + var content = link.Value; + _logger.Debug("Clear content ID: {ContentId}", content.Id); + + // clear the entire branch + ClearBranchLocked(content); + + // manage the tree + RemoveTreeNodeLocked(content); + + return true; } private void ClearBranchLocked(int id) @@ -711,84 +873,143 @@ namespace Umbraco.Web.PublishedCache.NuCache var id = content.FirstChildContentId; while (id > 0) { - var link = GetLinkedNode(id, "child"); + var link = GetRequiredLinkedNode(id, "child", null); ClearBranchLocked(link.Value); id = link.Value.NextSiblingContentId; } } - // gets the link node - // throws (panic) if not found, or no value - private LinkedNode GetLinkedNode(int id, string description) + /// + /// Gets the link node and if it doesn't exist throw a + /// + /// + /// + /// the generation requested, null for the latest stored + /// + private LinkedNode GetRequiredLinkedNode(int id, string description, long? gen) { - if (_contentNodes.TryGetValue(id, out var link) && link.Value != null) - return link; + if (_contentNodes.TryGetValue(id, out var link)) + { + link = GetLinkedNodeGen(link, gen); + if (link != null && link.Value != null) + return link; + } - throw new Exception($"panic: failed to get {description} with id={id}"); + throw new PanicException($"failed to get {description} with id={id}"); } - private LinkedNode GetParentLink(ContentNode content) + /// + /// Gets the parent link node, may be null or root if ParentContentId is less than 0 + /// + /// the generation requested, null for the latest stored + private LinkedNode GetParentLink(ContentNode content, long? gen) { - _contentNodes.TryGetValue(content.ParentContentId, out var link); // else null - //if (link == null || link.Value == null) - // throw new Exception("Panic: parent not found."); + if (content.ParentContentId < 0) + { + var root = GetLinkedNodeGen(_root, gen); + return root; + } + + if (_contentNodes.TryGetValue(content.ParentContentId, out var link)) + link = GetLinkedNodeGen(link, gen); + return link; } - private void RemoveNodeLocked(ContentNode content) + /// + /// Gets the linked parent node and if it doesn't exist throw a + /// + /// + /// the generation requested, null for the latest stored + /// + private LinkedNode GetRequiredParentLink(ContentNode content, long? gen) { + return content.ParentContentId < 0 ? _root : GetRequiredLinkedNode(content.ParentContentId, "parent", gen); + } + + /// + /// Iterates over the LinkedNode's generations to find the correct one + /// + /// + /// The generation requested, use null to avoid the lookup + /// + private LinkedNode GetLinkedNodeGen(LinkedNode link, long? gen) + where TValue : class + { + if (!gen.HasValue) return link; + + //find the correct snapshot, find the first that is <= the requested gen + while (link != null && link.Gen > gen) + { + link = link.Next; + } + return link; + } + + /// + /// This removes this current node from the tree hiearchy by removing it from it's parent's linked list + /// + /// + /// + /// This is called within a lock which means a new Gen is being created therefore this will not modify any existing content in a Gen. + /// + private void RemoveTreeNodeLocked(ContentNode content) + { + // NOTE: DO NOT modify `content` here, this would modify data for an existing Gen, all modifications are done to clones + // which would be targeting the new Gen. + var parentLink = content.ParentContentId < 0 ? _root - : GetLinkedNode(content.ParentContentId, "parent"); + : GetRequiredLinkedNode(content.ParentContentId, "parent", null); var parent = parentLink.Value; // must have children if (parent.FirstChildContentId < 0) - throw new Exception("panic: no children"); + throw new PanicException("no children"); - // if first, clone parent + remove first child - if (parent.FirstChildContentId == content.Id) - { + // if first/last, clone parent, then remove + + if (parent.FirstChildContentId == content.Id || parent.LastChildContentId == content.Id) parent = GenCloneLocked(parentLink); + + if (parent.FirstChildContentId == content.Id) parent.FirstChildContentId = content.NextSiblingContentId; - } - else + + if (parent.LastChildContentId == content.Id) + parent.LastChildContentId = content.PreviousSiblingContentId; + + // maintain linked list + + if (content.NextSiblingContentId > 0) { - // iterate children until the previous child - var link = GetLinkedNode(parent.FirstChildContentId, "first child"); - - while (link.Value.NextSiblingContentId != content.Id) - link = GetLinkedNode(link.Value.NextSiblingContentId, "next child"); - - // clone the previous child and replace next child - var prevChild = GenCloneLocked(link); - prevChild.NextSiblingContentId = content.NextSiblingContentId; + var nextLink = GetRequiredLinkedNode(content.NextSiblingContentId, "next sibling", null); + var next = GenCloneLocked(nextLink); + next.PreviousSiblingContentId = content.PreviousSiblingContentId; } - } - private bool ParentExistsLocked(ContentNodeKit kit) - { - if (kit.Node.ParentContentId < 0) - return true; - var link = GetParentLink(kit.Node); - return link?.Value != null; + if (content.PreviousSiblingContentId > 0) + { + var prevLink = GetRequiredLinkedNode(content.PreviousSiblingContentId, "previous sibling", null); + var prev = GenCloneLocked(prevLink); + prev.NextSiblingContentId = content.NextSiblingContentId; + } } private bool ParentPublishedLocked(ContentNodeKit kit) { if (kit.Node.ParentContentId < 0) return true; - var link = GetParentLink(kit.Node); + var link = GetParentLink(kit.Node, null); var node = link?.Value; - return node?.PublishedModel != null; + return node != null && node.HasPublished; } private ContentNode GenCloneLocked(LinkedNode link) { var node = link.Value; - if (node != null && link.Gen < _liveGen) + if (node != null && link.Gen != _liveGen) { node = new ContentNode(link.Value); if (link == _root) @@ -800,11 +1021,12 @@ namespace Umbraco.Web.PublishedCache.NuCache return node; } - private void AddNodeLocked(ContentNode content) + /// + /// Adds a node to the tree structure. + /// + private void AddTreeNodeLocked(ContentNode content, LinkedNode parentLink = null) { - var parentLink = content.ParentContentId < 0 - ? _root - : GetLinkedNode(content.ParentContentId, "parent"); + parentLink = parentLink ?? GetRequiredParentLink(content, null); var parent = parentLink.Value; @@ -813,35 +1035,72 @@ namespace Umbraco.Web.PublishedCache.NuCache { parent = GenCloneLocked(parentLink); parent.FirstChildContentId = content.Id; + parent.LastChildContentId = content.Id; return; } // get parent's first child - var childLink = GetLinkedNode(parent.FirstChildContentId, "first child"); + var childLink = GetRequiredLinkedNode(parent.FirstChildContentId, "first child", null); var child = childLink.Value; // if first, clone parent + insert as first child + // NOTE: Don't perform this check if loading from local DB since we know it's already sorted if (child.SortOrder > content.SortOrder) { content.NextSiblingContentId = parent.FirstChildContentId; + content.PreviousSiblingContentId = -1; + parent = GenCloneLocked(parentLink); parent.FirstChildContentId = content.Id; + + child = GenCloneLocked(childLink); + child.PreviousSiblingContentId = content.Id; + return; } - // else lookup position + // get parent's last child + var lastChildLink = GetRequiredLinkedNode(parent.LastChildContentId, "last child", null); + var lastChild = lastChildLink.Value; + + // if last, clone parent + append as last child + if (lastChild.SortOrder <= content.SortOrder) + { + content.PreviousSiblingContentId = parent.LastChildContentId; + content.NextSiblingContentId = -1; + + parent = GenCloneLocked(parentLink); + parent.LastChildContentId = content.Id; + + lastChild = GenCloneLocked(lastChildLink); + lastChild.NextSiblingContentId = content.Id; + + return; + } + + // else it's going somewhere in the middle, + // TODO: There was a note about performance when this occurs and that this only happens when moving and not very often, but that is not true, + // this also happens anytime a middle node is unpublished or republished (which causes a branch update), i'm unsure if this has perf impacts, + // i think this used to but it doesn't seem bad anymore that I can see... while (child.NextSiblingContentId > 0) { // get next child - var nextChildLink = GetLinkedNode(child.NextSiblingContentId, "next child"); + var nextChildLink = GetRequiredLinkedNode(child.NextSiblingContentId, "next child", null); var nextChild = nextChildLink.Value; // if here, clone previous + append/insert + // NOTE: Don't perform this check if loading from local DB since we know it's already sorted if (nextChild.SortOrder > content.SortOrder) { content.NextSiblingContentId = nextChild.Id; + content.PreviousSiblingContentId = nextChild.PreviousSiblingContentId; + child = GenCloneLocked(childLink); child.NextSiblingContentId = content.Id; + + var nnext = GenCloneLocked(nextChildLink); + nnext.PreviousSiblingContentId = content.Id; + return; } @@ -849,9 +1108,8 @@ namespace Umbraco.Web.PublishedCache.NuCache child = nextChild; } - // if last, clone previous + append - child = GenCloneLocked(childLink); - child.NextSiblingContentId = content.Id; + // should never get here + throw new PanicException("No more children."); } // replaces the root node @@ -905,7 +1163,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // this is safe only because we're write-locked foreach (var kvp in dict.Where(x => x.Value != null)) { - if (kvp.Value.Gen < _liveGen) + if (kvp.Value.Gen != _liveGen) { var link = new LinkedNode(null, _liveGen, kvp.Value); dict.TryUpdate(kvp.Key, link, kvp.Value); @@ -931,21 +1189,15 @@ namespace Umbraco.Web.PublishedCache.NuCache public IEnumerable GetAtRoot(long gen) { - var z = _root; - while (z != null) - { - if (z.Gen <= gen) - break; - z = z.Next; - } - if (z == null) + var root = GetLinkedNodeGen(_root, gen); + if (root == null) yield break; - var id = z.Value.FirstChildContentId; + var id = root.Value.FirstChildContentId; while (id > 0) { - var link = GetLinkedNode(id, "sibling"); + var link = GetRequiredLinkedNode(id, "root", gen); yield return link.Value; id = link.Value.NextSiblingContentId; } @@ -956,13 +1208,8 @@ namespace Umbraco.Web.PublishedCache.NuCache { // look ma, no lock! var link = GetHead(dict, key); - while (link != null) - { - if (link.Gen <= gen) - return link.Value; // may be null - link = link.Next; - } - return null; + link = GetLinkedNodeGen(link, gen); + return link?.Value; // may be null } public IEnumerable GetAll(long gen) @@ -972,17 +1219,9 @@ namespace Umbraco.Web.PublishedCache.NuCache var links = _contentNodes.Values.ToArray(); foreach (var l in links) { - var link = l; - while (link != null) - { - if (link.Gen <= gen) - { - if (link.Value != null) - yield return link.Value; - break; - } - link = link.Next; - } + var link = GetLinkedNodeGen(l, gen); + if (link?.Value != null) + yield return link.Value; } } @@ -990,14 +1229,8 @@ namespace Umbraco.Web.PublishedCache.NuCache { var has = _contentNodes.Any(x => { - var link = x.Value; - while (link != null) - { - if (link.Gen <= gen && link.Value != null) - return true; - link = link.Next; - } - return false; + var link = GetLinkedNodeGen(x.Value, gen); + return link?.Value != null; }); return has == false; } @@ -1018,11 +1251,8 @@ namespace Umbraco.Web.PublishedCache.NuCache public Snapshot CreateSnapshot() { - var lockInfo = new ReadLockInfo(); - try + lock(_rlocko) { - Lock(lockInfo); - // if no next generation is required, and we already have one, // use it and create a new snapshot if (_nextGen == false && _genObj != null) @@ -1035,7 +1265,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // else we need to try to create a new gen ref // whether we are wlocked or not, noone can rlock while we do, // so _liveGen and _nextGen are safe - if (_wlocked > 0) // volatile, cannot ++ but could -- + if (Monitor.IsEntered(_wlocko)) { // write-locked, cannot use latest gen (at least 1) so use previous var snapGen = _nextGen ? _liveGen - 1 : _liveGen; @@ -1044,7 +1274,7 @@ namespace Umbraco.Web.PublishedCache.NuCache if (_genObj == null) _genObjs.Enqueue(_genObj = new GenObj(snapGen)); else if (_genObj.Gen != snapGen) - throw new Exception("panic"); + throw new PanicException($"The generation {_genObj.Gen} does not equal the snapshot generation {snapGen}"); } else { @@ -1074,10 +1304,6 @@ namespace Umbraco.Web.PublishedCache.NuCache return snapshot; } - finally - { - Release(lockInfo); - } } public Snapshot LiveSnapshot => new Snapshot(this, _liveGen @@ -1195,16 +1421,17 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - public async Task WaitForPendingCollect() - { - Task task; - lock (_rlocko) - { - task = _collectTask; - } - if (task != null) - await task; - } + // TODO: This is never used? Should it be? Maybe move to TestHelper below? + //public async Task WaitForPendingCollect() + //{ + // Task task; + // lock (_rlocko) + // { + // task = _collectTask; + // } + // if (task != null) + // await task; + //} public long GenCount => _genObjs.Count; @@ -1212,7 +1439,7 @@ namespace Umbraco.Web.PublishedCache.NuCache #endregion - #region Unit testing + #region Internals/Unit testing private TestHelper _unitTesting; @@ -1235,17 +1462,22 @@ namespace Umbraco.Web.PublishedCache.NuCache set => _store._collectAuto = value; } - public Tuple[] GetValues(int id) + /// + /// Return a list of Gen/ContentNode values + /// + /// + /// + public (long gen, ContentNode contentNode)[] GetValues(int id) { _store._contentNodes.TryGetValue(id, out LinkedNode link); // else null if (link == null) - return new Tuple[0]; + return Array.Empty<(long, ContentNode)>(); - var tuples = new List>(); + var tuples = new List<(long, ContentNode)>(); do { - tuples.Add(Tuple.Create(link.Gen, link.Value)); + tuples.Add((link.Gen, link.Value)); link = link.Next; } while (link != null); return tuples.ToArray(); diff --git a/src/Umbraco.Web/PublishedCache/NuCache/DataSource/BTree.ContentNodeKitSerializer.cs b/src/Umbraco.Web/PublishedCache/NuCache/DataSource/BTree.ContentNodeKitSerializer.cs index 4303f97d3c..f799869850 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/DataSource/BTree.ContentNodeKitSerializer.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/DataSource/BTree.ContentNodeKitSerializer.cs @@ -54,4 +54,4 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource DataSerializer.WriteTo(value.PublishedData, stream); } } -} \ No newline at end of file +} diff --git a/src/Umbraco.Web/PublishedCache/NuCache/DataSource/DatabaseDataSource.cs b/src/Umbraco.Web/PublishedCache/NuCache/DataSource/DatabaseDataSource.cs index 3b66bd52d0..19aab7ea65 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/DataSource/DatabaseDataSource.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/DataSource/DatabaseDataSource.cs @@ -67,7 +67,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource { var sql = ContentSourcesSelect(scope) .Where(x => x.NodeObjectType == Constants.ObjectTypes.Document && x.NodeId == id && !x.Trashed) - .OrderBy(x => x.Level, x => x.SortOrder); + .OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder); var dto = scope.Database.Fetch(sql).FirstOrDefault(); return dto == null ? new ContentNodeKit() : CreateContentNodeKit(dto); @@ -77,7 +77,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource { var sql = ContentSourcesSelect(scope) .Where(x => x.NodeObjectType == Constants.ObjectTypes.Document && !x.Trashed) - .OrderBy(x => x.Level, x => x.SortOrder); + .OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder); return scope.Database.Query(sql).Select(CreateContentNodeKit); } @@ -91,7 +91,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource .Where(x => x.NodeObjectType == Constants.ObjectTypes.Document && !x.Trashed) .Where(x => x.NodeId == id, "x") - .OrderBy(x => x.Level, x => x.SortOrder); + .OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder); return scope.Database.Query(sql).Select(CreateContentNodeKit); } @@ -103,7 +103,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource var sql = ContentSourcesSelect(scope) .Where(x => x.NodeObjectType == Constants.ObjectTypes.Document && !x.Trashed) .WhereIn(x => x.ContentTypeId, ids) - .OrderBy(x => x.Level, x => x.SortOrder); + .OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder); return scope.Database.Query(sql).Select(CreateContentNodeKit); } @@ -140,7 +140,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource { var sql = MediaSourcesSelect(scope) .Where(x => x.NodeObjectType == Constants.ObjectTypes.Media && x.NodeId == id && !x.Trashed) - .OrderBy(x => x.Level, x => x.SortOrder); + .OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder); var dto = scope.Database.Fetch(sql).FirstOrDefault(); return dto == null ? new ContentNodeKit() : CreateMediaNodeKit(dto); @@ -150,7 +150,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource { var sql = MediaSourcesSelect(scope) .Where(x => x.NodeObjectType == Constants.ObjectTypes.Media && !x.Trashed) - .OrderBy(x => x.Level, x => x.SortOrder); + .OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder); return scope.Database.Query(sql).Select(CreateMediaNodeKit); } @@ -164,7 +164,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource .Where(x => x.NodeObjectType == Constants.ObjectTypes.Media && !x.Trashed) .Where(x => x.NodeId == id, "x") - .OrderBy(x => x.Level, x => x.SortOrder); + .OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder); return scope.Database.Query(sql).Select(CreateMediaNodeKit); } @@ -176,7 +176,7 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource var sql = MediaSourcesSelect(scope) .Where(x => x.NodeObjectType == Constants.ObjectTypes.Media && !x.Trashed) .WhereIn(x => x.ContentTypeId, ids) - .OrderBy(x => x.Level, x => x.SortOrder); + .OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder); return scope.Database.Query(sql).Select(CreateMediaNodeKit); } @@ -207,7 +207,8 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource VersionDate = dto.EditVersionDate, WriterId = dto.EditWriterId, Properties = nested.PropertyData, - CultureInfos = nested.CultureData + CultureInfos = nested.CultureData, + UrlSegment = nested.UrlSegment }; } } diff --git a/src/Umbraco.Web/PublishedCache/NuCache/DataSource/IDataSource.cs b/src/Umbraco.Web/PublishedCache/NuCache/DataSource/IDataSource.cs index d4702cf7aa..ec3ab38e84 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/DataSource/IDataSource.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/DataSource/IDataSource.cs @@ -8,14 +8,70 @@ namespace Umbraco.Web.PublishedCache.NuCache.DataSource /// internal interface IDataSource { + //TODO: For these required sort orders, would sorting on Path 'just work'? + ContentNodeKit GetContentSource(IScope scope, int id); - IEnumerable GetAllContentSources(IScope scope); // must order by level - IEnumerable GetBranchContentSources(IScope scope, int id); // must order by level, sortOrder + + /// + /// Returns all content ordered by level + sortOrder + /// + /// + /// + /// + /// MUST be ordered by level + parentId + sortOrder! + /// + IEnumerable GetAllContentSources(IScope scope); + + /// + /// Returns branch for content ordered by level + sortOrder + /// + /// + /// + /// + /// MUST be ordered by level + parentId + sortOrder! + /// + IEnumerable GetBranchContentSources(IScope scope, int id); + + /// + /// Returns content by Ids ordered by level + sortOrder + /// + /// + /// + /// + /// MUST be ordered by level + parentId + sortOrder! + /// IEnumerable GetTypeContentSources(IScope scope, IEnumerable ids); ContentNodeKit GetMediaSource(IScope scope, int id); - IEnumerable GetAllMediaSources(IScope scope); // must order by level + + /// + /// Returns all media ordered by level + sortOrder + /// + /// + /// + /// + /// MUST be ordered by level + parentId + sortOrder! + /// + IEnumerable GetAllMediaSources(IScope scope); + + /// + /// Returns branch for media ordered by level + sortOrder + /// + /// + /// + /// + /// MUST be ordered by level + parentId + sortOrder! + /// IEnumerable GetBranchMediaSources(IScope scope, int id); // must order by level, sortOrder + + /// + /// Returns media by Ids ordered by level + sortOrder + /// + /// + /// + /// + /// MUST be ordered by level + parentId + sortOrder! + /// IEnumerable GetTypeMediaSources(IScope scope, IEnumerable ids); } } diff --git a/src/Umbraco.Web/PublishedCache/NuCache/PublishedContent.cs b/src/Umbraco.Web/PublishedCache/NuCache/PublishedContent.cs index cb7acdc164..3c3c3ac54f 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/PublishedContent.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/PublishedContent.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using Umbraco.Core; using Umbraco.Core.Cache; +using Umbraco.Core.Exceptions; using Umbraco.Core.Models.PublishedContent; using Umbraco.Web.Composing; using Umbraco.Web.Models; @@ -119,7 +120,7 @@ namespace Umbraco.Web.PublishedCache.NuCache case PublishedItemType.Media: return GetMediaByIdFunc; default: - throw new Exception("panic: invalid item type"); + throw new PanicException("invalid item type"); } } @@ -194,7 +195,7 @@ namespace Umbraco.Web.PublishedCache.NuCache }; if (ContentData.CultureInfos == null) - throw new Exception("panic: _contentDate.CultureInfos is null."); + throw new PanicException("_contentDate.CultureInfos is null."); return _cultures = ContentData.CultureInfos .ToDictionary(x => x.Key, x => new PublishedCultureInfo(x.Key, x.Value.Name, x.Value.UrlSegment, x.Value.Date), StringComparer.OrdinalIgnoreCase); @@ -232,8 +233,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // invariant content items) // if there is no 'published' published content, no culture can be published - var hasPublished = _contentNode.PublishedContent != null; - if (!hasPublished) + if (!_contentNode.HasPublished) return false; // if there is a 'published' published content, and does not vary = published @@ -246,7 +246,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // there is a 'published' published content, and varies // = depends on the culture - return _contentNode.PublishedContent.ContentData.CultureInfos.ContainsKey(culture); + return _contentNode.HasPublishedCulture(culture); } #endregion @@ -286,13 +286,13 @@ namespace Umbraco.Web.PublishedCache.NuCache { // but if IsPreviewing is true, we should have a child if (IsPreviewing) - throw new Exception($"panic: failed to get content with id={id}"); + throw new PanicException($"failed to get content with id={id}"); // if IsPreviewing is false, get the unpublished child nevertheless // we need it to keep enumerating children! but we don't return it content = getById(publishedSnapshot, true, id); if (content == null) - throw new Exception($"panic: failed to get content with id={id}"); + throw new PanicException($"failed to get content with id={id}"); } id = UnwrapIPublishedContent(content)._contentNode.NextSiblingContentId; @@ -326,7 +326,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // beware what you use that one for - you don't want to cache its result private IAppCache GetAppropriateCache() { - var publishedSnapshot = (PublishedSnapshot)_publishedSnapshotAccessor.PublishedSnapshot; + var publishedSnapshot = _publishedSnapshotAccessor.PublishedSnapshot; var cache = publishedSnapshot == null ? null : ((IsPreviewing == false || PublishedSnapshotService.FullCacheWhenPreviewing) && (ContentType.ItemType != PublishedItemType.Member) @@ -337,7 +337,7 @@ namespace Umbraco.Web.PublishedCache.NuCache private IAppCache GetCurrentSnapshotCache() { - var publishedSnapshot = (PublishedSnapshot)_publishedSnapshotAccessor.PublishedSnapshot; + var publishedSnapshot = _publishedSnapshotAccessor.PublishedSnapshot; return publishedSnapshot?.SnapshotCache; } diff --git a/src/Umbraco.Web/PublishedCache/NuCache/PublishedSnapshotService.cs b/src/Umbraco.Web/PublishedCache/NuCache/PublishedSnapshotService.cs index 6e7916c77f..833b0a3665 100755 --- a/src/Umbraco.Web/PublishedCache/NuCache/PublishedSnapshotService.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/PublishedSnapshotService.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using CSharpTest.Net.Collections; using Newtonsoft.Json; using Umbraco.Core; @@ -37,7 +38,7 @@ namespace Umbraco.Web.PublishedCache.NuCache private readonly IPublishedContentTypeFactory _publishedContentTypeFactory; private readonly IScopeProvider _scopeProvider; private readonly IDataSource _dataSource; - private readonly ILogger _logger; + private readonly IProfilingLogger _logger; private readonly IDocumentRepository _documentRepository; private readonly IMediaRepository _mediaRepository; private readonly IMemberRepository _memberRepository; @@ -54,10 +55,12 @@ namespace Umbraco.Web.PublishedCache.NuCache private readonly ContentStore _mediaStore; private readonly SnapDictionary _domainStore; private readonly object _storesLock = new object(); + private readonly object _elementsLock = new object(); private BPlusTree _localContentDb; private BPlusTree _localMediaDb; - private bool _localDbExists; + private bool _localContentDbExists; + private bool _localMediaDbExists; // define constant - determines whether to use cache when previewing // to store eg routes, property converted values, anything - caching @@ -71,7 +74,7 @@ namespace Umbraco.Web.PublishedCache.NuCache public PublishedSnapshotService(PublishedSnapshotServiceOptions options, IMainDom mainDom, IRuntimeState runtime, ServiceContext serviceContext, IPublishedContentTypeFactory publishedContentTypeFactory, IdkMap idkMap, - IPublishedSnapshotAccessor publishedSnapshotAccessor, IVariationContextAccessor variationContextAccessor, ILogger logger, IScopeProvider scopeProvider, + IPublishedSnapshotAccessor publishedSnapshotAccessor, IVariationContextAccessor variationContextAccessor, IProfilingLogger logger, IScopeProvider scopeProvider, IDocumentRepository documentRepository, IMediaRepository mediaRepository, IMemberRepository memberRepository, IDefaultCultureAccessor defaultCultureAccessor, IDataSource dataSource, IGlobalSettings globalSettings, @@ -113,54 +116,37 @@ namespace Umbraco.Web.PublishedCache.NuCache if (runtime.Level != RuntimeLevel.Run) return; - if (options.IgnoreLocalDb == false) + // lock this entire call, we only want a single thread to be accessing the stores at once and within + // the call below to mainDom.Register, a callback may occur on a threadpool thread to MainDomRelease + // at the same time as we are trying to write to the stores. MainDomRelease also locks on _storesLock so + // it will not be able to close the stores until we are done populating (if the store is empty) + lock (_storesLock) { - var registered = mainDom.Register( - () => - { - //"install" phase of MainDom - //this is inside of a lock in MainDom so this is guaranteed to run if MainDom was acquired and guaranteed - //to not run if MainDom wasn't acquired. - //If MainDom was not acquired, then _localContentDb and _localMediaDb will remain null which means this appdomain - //will load in published content via the DB and in that case this appdomain will probably not exist long enough to - //serve more than a page of content. + if (options.IgnoreLocalDb == false) + { + var registered = mainDom.Register(MainDomRegister, MainDomRelease); - var path = GetLocalFilesPath(); - var localContentDbPath = Path.Combine(path, "NuCache.Content.db"); - var localMediaDbPath = Path.Combine(path, "NuCache.Media.db"); - _localDbExists = File.Exists(localContentDbPath) && File.Exists(localMediaDbPath); - // if both local databases exist then GetTree will open them, else new databases will be created - _localContentDb = BTree.GetTree(localContentDbPath, _localDbExists); - _localMediaDb = BTree.GetTree(localMediaDbPath, _localDbExists); - }, - () => - { - //"release" phase of MainDom + // stores are created with a db so they can write to it, but they do not read from it, + // stores need to be populated, happens in OnResolutionFrozen which uses _localDbExists to + // figure out whether it can read the databases or it should populate them from sql - lock (_storesLock) - { - _contentStore?.ReleaseLocalDb(); //null check because we could shut down before being assigned - _localContentDb = null; - _mediaStore?.ReleaseLocalDb(); //null check because we could shut down before being assigned - _localMediaDb = null; - } - }); + _logger.Info("Creating the content store, localContentDbExists? {LocalContentDbExists}", _localContentDbExists); + _contentStore = new ContentStore(publishedSnapshotAccessor, variationContextAccessor, logger, _localContentDb); + _logger.Info("Creating the media store, localMediaDbExists? {LocalMediaDbExists}", _localMediaDbExists); + _mediaStore = new ContentStore(publishedSnapshotAccessor, variationContextAccessor, logger, _localMediaDb); + } + else + { + _logger.Info("Creating the content store (local db ignored)"); + _contentStore = new ContentStore(publishedSnapshotAccessor, variationContextAccessor, logger); + _logger.Info("Creating the media store (local db ignored)"); + _mediaStore = new ContentStore(publishedSnapshotAccessor, variationContextAccessor, logger); + } - // stores are created with a db so they can write to it, but they do not read from it, - // stores need to be populated, happens in OnResolutionFrozen which uses _localDbExists to - // figure out whether it can read the databases or it should populate them from sql - _contentStore = new ContentStore(publishedSnapshotAccessor, variationContextAccessor, logger, _localContentDb); - _mediaStore = new ContentStore(publishedSnapshotAccessor, variationContextAccessor, logger, _localMediaDb); + _domainStore = new SnapDictionary(); + + LoadCachesOnStartup(); } - else - { - _contentStore = new ContentStore(publishedSnapshotAccessor, variationContextAccessor, logger); - _mediaStore = new ContentStore(publishedSnapshotAccessor, variationContextAccessor, logger); - } - - _domainStore = new SnapDictionary(); - - publishedModelFactory.WithSafeLiveFactory(LoadCaches); Guid GetUid(ContentStore store, int id) => store.LiveSnapshot.Get(id)?.Uid ?? default; int GetId(ContentStore store, Guid uid) => store.LiveSnapshot.Get(uid)?.Id ?? default; @@ -172,45 +158,99 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - private void LoadCaches() + /// + /// Install phase of + /// + /// + /// This is inside of a lock in MainDom so this is guaranteed to run if MainDom was acquired and guaranteed + /// to not run if MainDom wasn't acquired. + /// If MainDom was not acquired, then _localContentDb and _localMediaDb will remain null which means this appdomain + /// will load in published content via the DB and in that case this appdomain will probably not exist long enough to + /// serve more than a page of content. + /// + private void MainDomRegister() { + var path = GetLocalFilesPath(); + var localContentDbPath = Path.Combine(path, "NuCache.Content.db"); + var localMediaDbPath = Path.Combine(path, "NuCache.Media.db"); + + _localContentDbExists = File.Exists(localContentDbPath); + _localMediaDbExists = File.Exists(localMediaDbPath); + + // if both local databases exist then GetTree will open them, else new databases will be created + _localContentDb = BTree.GetTree(localContentDbPath, _localContentDbExists); + _localMediaDb = BTree.GetTree(localMediaDbPath, _localMediaDbExists); + + _logger.Info("Registered with MainDom, localContentDbExists? {LocalContentDbExists}, localMediaDbExists? {LocalMediaDbExists}", _localContentDbExists, _localMediaDbExists); + } + + /// + /// Release phase of MainDom + /// + /// + /// This will execute on a threadpool thread + /// + private void MainDomRelease() + { + _logger.Debug("Releasing from MainDom..."); + lock (_storesLock) { - // populate the stores + _logger.Debug("Releasing content store..."); + _contentStore?.ReleaseLocalDb(); //null check because we could shut down before being assigned + _localContentDb = null; - try - { - var okContent = false; - var okMedia = false; + _logger.Debug("Releasing media store..."); + _mediaStore?.ReleaseLocalDb(); //null check because we could shut down before being assigned + _localMediaDb = null; - if (_localDbExists) - { - okContent = LockAndLoadContent(LoadContentFromLocalDbLocked); - if (!okContent) - _logger.Warn("Loading content from local db raised warnings, will reload from database."); - okMedia = LockAndLoadMedia(LoadMediaFromLocalDbLocked); - if (!okMedia) - _logger.Warn("Loading media from local db raised warnings, will reload from database."); - } - - if (!okContent) - LockAndLoadContent(LoadContentFromDatabaseLocked); - - if (!okMedia) - LockAndLoadMedia(LoadMediaFromDatabaseLocked); - - LockAndLoadDomains(); - } - catch (Exception ex) - { - _logger.Fatal(ex, "Panic, exception while loading cache data."); - } - - // finally, cache is ready! - _isReady = true; + _logger.Info("Released from MainDom"); } } + /// + /// Populates the stores + /// + /// This is called inside of a lock for _storesLock + private void LoadCachesOnStartup() + { + var okContent = false; + var okMedia = false; + + try + { + if (_localContentDbExists) + { + okContent = LockAndLoadContent(scope => LoadContentFromLocalDbLocked(true)); + if (!okContent) + _logger.Warn("Loading content from local db raised warnings, will reload from database."); + } + + if (_localMediaDbExists) + { + okMedia = LockAndLoadMedia(scope => LoadMediaFromLocalDbLocked(true)); + if (!okMedia) + _logger.Warn("Loading media from local db raised warnings, will reload from database."); + } + + if (!okContent) + LockAndLoadContent(scope => LoadContentFromDatabaseLocked(scope, true)); + + if (!okMedia) + LockAndLoadMedia(scope => LoadMediaFromDatabaseLocked(scope, true)); + + LockAndLoadDomains(); + } + catch (Exception ex) + { + _logger.Fatal(ex, "Panic, exception while loading cache data."); + throw; + } + + // finally, cache is ready! + _isReady = true; + } + private void InitializeRepositoryEvents() { // TODO: The reason these events are in the repository is for legacy, the events should exist at the service @@ -233,6 +273,8 @@ namespace Umbraco.Web.PublishedCache.NuCache ContentTypeService.ScopedRefreshedEntity += OnContentTypeRefreshedEntity; MediaTypeService.ScopedRefreshedEntity += OnMediaTypeRefreshedEntity; MemberTypeService.ScopedRefreshedEntity += OnMemberTypeRefreshedEntity; + + LocalizationService.SavedLanguage += OnLanguageSaved; } private void TearDownRepositoryEvents() @@ -250,6 +292,8 @@ namespace Umbraco.Web.PublishedCache.NuCache ContentTypeService.ScopedRefreshedEntity -= OnContentTypeRefreshedEntity; MediaTypeService.ScopedRefreshedEntity -= OnMediaTypeRefreshedEntity; MemberTypeService.ScopedRefreshedEntity -= OnMemberTypeRefreshedEntity; + + LocalizationService.SavedLanguage -= OnLanguageSaved; } public override void Dispose() @@ -314,22 +358,10 @@ namespace Umbraco.Web.PublishedCache.NuCache // before I read it? NO! because the WHOLE content tree is read-locked using WithReadLocked. // don't panic. - private void LockAndLoadContent(Action action) - { - // first get a writer, then a scope - // if there already is a scope, the writer will attach to it - // otherwise, it will only exist here - cheap - using (_contentStore.GetScopedWriteLock(_scopeProvider)) - using (var scope = _scopeProvider.CreateScope()) - { - scope.ReadLock(Constants.Locks.ContentTree); - action(scope); - scope.Complete(); - } - } - private bool LockAndLoadContent(Func action) { + + // first get a writer, then a scope // if there already is a scope, the writer will attach to it // otherwise, it will only exist here - cheap @@ -343,7 +375,7 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - private void LoadContentFromDatabaseLocked(IScope scope) + private bool LoadContentFromDatabaseLocked(IScope scope, bool onStartup) { // locks: // contentStore is wlocked (1 thread) @@ -351,39 +383,35 @@ namespace Umbraco.Web.PublishedCache.NuCache var contentTypes = _serviceContext.ContentTypeService.GetAll() .Select(x => _publishedContentTypeFactory.CreateContentType(x)); - _contentStore.SetAllContentTypes(contentTypes); - // beware! at that point the cache is inconsistent, - // assuming we are going to SetAll content items! + _contentStore.SetAllContentTypesLocked(contentTypes); - _localContentDb?.Clear(); + using (_logger.TraceDuration("Loading content from database")) + { + // beware! at that point the cache is inconsistent, + // assuming we are going to SetAll content items! - _logger.Debug("Loading content from database..."); - var sw = Stopwatch.StartNew(); - // IMPORTANT GetAllContentSources sorts kits by level - var kits = _dataSource.GetAllContentSources(scope); - _contentStore.SetAll(kits); - sw.Stop(); - _logger.Debug("Loaded content from database ({Duration}ms)", sw.ElapsedMilliseconds); + _localContentDb?.Clear(); + + // IMPORTANT GetAllContentSources sorts kits by level + parentId + sortOrder + var kits = _dataSource.GetAllContentSources(scope); + return onStartup ? _contentStore.SetAllFastSortedLocked(kits, true) : _contentStore.SetAllLocked(kits); + } } - private bool LoadContentFromLocalDbLocked(IScope scope) + private bool LoadContentFromLocalDbLocked(bool onStartup) { var contentTypes = _serviceContext.ContentTypeService.GetAll() - .Select(x => _publishedContentTypeFactory.CreateContentType(x)); - _contentStore.SetAllContentTypes(contentTypes); + .Select(x => _publishedContentTypeFactory.CreateContentType(x)); + _contentStore.SetAllContentTypesLocked(contentTypes); - // beware! at that point the cache is inconsistent, - // assuming we are going to SetAll content items! + using (_logger.TraceDuration("Loading content from local cache file")) + { + // beware! at that point the cache is inconsistent, + // assuming we are going to SetAll content items! - _logger.Debug("Loading content from local db..."); - var sw = Stopwatch.StartNew(); - var kits = _localContentDb.Select(x => x.Value) - .OrderBy(x => x.Node.Level); // IMPORTANT sort by level - var ok = _contentStore.SetAll(kits, true); - sw.Stop(); - _logger.Debug("Loaded content from local db ({Duration}ms)", sw.ElapsedMilliseconds); - return ok; + return LoadEntitiesFromLocalDbLocked(onStartup, _localContentDb, _contentStore, "content"); + } } // keep these around - might be useful @@ -408,18 +436,6 @@ namespace Umbraco.Web.PublishedCache.NuCache // _contentStore.Set(contentNode); //} - private void LockAndLoadMedia(Action action) - { - // see note in LockAndLoadContent - using (_mediaStore.GetScopedWriteLock(_scopeProvider)) - using (var scope = _scopeProvider.CreateScope()) - { - scope.ReadLock(Constants.Locks.MediaTree); - action(scope); - scope.Complete(); - } - } - private bool LockAndLoadMedia(Func action) { // see note in LockAndLoadContent @@ -433,45 +449,74 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - private void LoadMediaFromDatabaseLocked(IScope scope) + private bool LoadMediaFromDatabaseLocked(IScope scope, bool onStartup) { // locks & notes: see content var mediaTypes = _serviceContext.MediaTypeService.GetAll() .Select(x => _publishedContentTypeFactory.CreateContentType(x)); - _mediaStore.SetAllContentTypes(mediaTypes); + _mediaStore.SetAllContentTypesLocked(mediaTypes); - // beware! at that point the cache is inconsistent, - // assuming we are going to SetAll content items! + using (_logger.TraceDuration("Loading media from database")) + { + // beware! at that point the cache is inconsistent, + // assuming we are going to SetAll content items! - _localMediaDb?.Clear(); + _localMediaDb?.Clear(); - _logger.Debug("Loading media from database..."); - var sw = Stopwatch.StartNew(); - // IMPORTANT GetAllMediaSources sorts kits by level - var kits = _dataSource.GetAllMediaSources(scope); - _mediaStore.SetAll(kits); - sw.Stop(); - _logger.Debug("Loaded media from database ({Duration}ms)", sw.ElapsedMilliseconds); + _logger.Debug("Loading media from database..."); + // IMPORTANT GetAllMediaSources sorts kits by level + parentId + sortOrder + var kits = _dataSource.GetAllMediaSources(scope); + return onStartup ? _mediaStore.SetAllFastSortedLocked(kits, true) : _mediaStore.SetAllLocked(kits); + } } - private bool LoadMediaFromLocalDbLocked(IScope scope) + private bool LoadMediaFromLocalDbLocked(bool onStartup) { var mediaTypes = _serviceContext.MediaTypeService.GetAll() - .Select(x => _publishedContentTypeFactory.CreateContentType(x)); - _mediaStore.SetAllContentTypes(mediaTypes); + .Select(x => _publishedContentTypeFactory.CreateContentType(x)); + _mediaStore.SetAllContentTypesLocked(mediaTypes); - // beware! at that point the cache is inconsistent, - // assuming we are going to SetAll content items! + using (_logger.TraceDuration("Loading media from local cache file")) + { + // beware! at that point the cache is inconsistent, + // assuming we are going to SetAll content items! - _logger.Debug("Loading media from local db..."); - var sw = Stopwatch.StartNew(); - var kits = _localMediaDb.Select(x => x.Value) - .OrderBy(x => x.Node.Level); // IMPORTANT sort by level - var ok = _mediaStore.SetAll(kits, true); - sw.Stop(); - _logger.Debug("Loaded media from local db ({Duration}ms)", sw.ElapsedMilliseconds); - return ok; + return LoadEntitiesFromLocalDbLocked(onStartup, _localMediaDb, _mediaStore, "media"); + } + + } + + private bool LoadEntitiesFromLocalDbLocked(bool onStartup, BPlusTree localDb, ContentStore store, string entityType) + { + var kits = localDb.Select(x => x.Value) + .OrderBy(x => x.Node.Level) + .ThenBy(x => x.Node.ParentContentId) + .ThenBy(x => x.Node.SortOrder) // IMPORTANT sort by level + parentId + sortOrder + .ToList(); + + if (kits.Count == 0) + { + // If there's nothing in the local cache file, we should return false? YES even though the site legitately might be empty. + // Is it possible that the cache file is empty but the database is not? YES... (well, it used to be possible) + // * A new file is created when one doesn't exist, this will only be done when MainDom is acquired + // * The new file will be populated as soon as LoadCachesOnStartup is called + // * If the appdomain is going down the moment after MainDom was acquired and we've created an empty cache file, + // then the MainDom release callback is triggered from on a different thread, which will close the file and + // set the cache file reference to null. At this moment, it is possible that the file is closed and the + // reference is set to null BEFORE LoadCachesOnStartup which would mean that the current appdomain would load + // in the in-mem cache via DB calls, BUT this now means that there is an empty cache file which will be + // loaded by the next appdomain and it won't check if it's empty, it just assumes that since the cache + // file is there, that is correct. + + // Update: We will still return false here even though the above mentioned race condition has been fixed since we now + // lock the entire operation of creating/populating the cache file with the same lock as releasing/closing the cache file + + _logger.Info($"Tried to load {entityType} from the local cache file but it was empty."); + return false; + } + + return onStartup ? store.SetAllFastSortedLocked(kits, false) : store.SetAllLocked(kits); } // keep these around - might be useful @@ -577,7 +622,7 @@ namespace Umbraco.Web.PublishedCache.NuCache .Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false) .Select(x => new Domain(x.Id, x.DomainName, x.RootContentId.Value, CultureInfo.GetCultureInfo(x.LanguageIsoCode), x.IsWildcard))) { - _domainStore.Set(domain.Id, domain); + _domainStore.SetLocked(domain.Id, domain); } } @@ -625,10 +670,12 @@ namespace Umbraco.Web.PublishedCache.NuCache publishedChanged = publishedChanged2; } + if (draftChanged || publishedChanged) ((PublishedSnapshot)CurrentPublishedSnapshot)?.Resync(); } + // Calling this method means we have a lock on the contentStore (i.e. GetScopedWriteLock) private void NotifyLocked(IEnumerable payloads, out bool draftChanged, out bool publishedChanged) { publishedChanged = false; @@ -638,7 +685,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // content (and content types) are read-locked while reading content // contentStore is wlocked (so readable, only no new views) // and it can be wlocked by 1 thread only at a time - // contentStore is write-locked during changes + // contentStore is write-locked during changes - see note above, calls to this method are wrapped in contentStore.GetScopedWriteLock foreach (var payload in payloads) { @@ -649,7 +696,7 @@ namespace Umbraco.Web.PublishedCache.NuCache using (var scope = _scopeProvider.CreateScope()) { scope.ReadLock(Constants.Locks.ContentTree); - LoadContentFromDatabaseLocked(scope); + LoadContentFromDatabaseLocked(scope, false); scope.Complete(); } draftChanged = publishedChanged = true; @@ -658,7 +705,7 @@ namespace Umbraco.Web.PublishedCache.NuCache if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) { - if (_contentStore.Clear(payload.Id)) + if (_contentStore.ClearLocked(payload.Id)) draftChanged = publishedChanged = true; continue; } @@ -681,7 +728,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // ?? should we do some RV check here? // IMPORTANT GetbranchContentSources sorts kits by level and by sort order var kits = _dataSource.GetBranchContentSources(scope, capture.Id); - _contentStore.SetBranch(capture.Id, kits); + _contentStore.SetBranchLocked(capture.Id, kits); } else { @@ -689,11 +736,11 @@ namespace Umbraco.Web.PublishedCache.NuCache var kit = _dataSource.GetContentSource(scope, capture.Id); if (kit.IsEmpty) { - _contentStore.Clear(capture.Id); + _contentStore.ClearLocked(capture.Id); } else { - _contentStore.Set(kit); + _contentStore.SetLocked(kit); } } @@ -742,7 +789,7 @@ namespace Umbraco.Web.PublishedCache.NuCache using (var scope = _scopeProvider.CreateScope()) { scope.ReadLock(Constants.Locks.MediaTree); - LoadMediaFromDatabaseLocked(scope); + LoadMediaFromDatabaseLocked(scope, false); scope.Complete(); } anythingChanged = true; @@ -751,7 +798,7 @@ namespace Umbraco.Web.PublishedCache.NuCache if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) { - if (_mediaStore.Clear(payload.Id)) + if (_mediaStore.ClearLocked(payload.Id)) anythingChanged = true; continue; } @@ -774,7 +821,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // ?? should we do some RV check here? // IMPORTANT GetbranchContentSources sorts kits by level and by sort order var kits = _dataSource.GetBranchMediaSources(scope, capture.Id); - _mediaStore.SetBranch(capture.Id, kits); + _mediaStore.SetBranchLocked(capture.Id, kits); } else { @@ -782,11 +829,11 @@ namespace Umbraco.Web.PublishedCache.NuCache var kit = _dataSource.GetMediaSource(scope, capture.Id); if (kit.IsEmpty) { - _mediaStore.Clear(capture.Id); + _mediaStore.ClearLocked(capture.Id); } else { - _mediaStore.Set(kit); + _mediaStore.SetLocked(kit); } } @@ -822,8 +869,8 @@ namespace Umbraco.Web.PublishedCache.NuCache using (_contentStore.GetScopedWriteLock(_scopeProvider)) using (_mediaStore.GetScopedWriteLock(_scopeProvider)) { - NotifyLocked(new[] { new ContentCacheRefresher.JsonPayload(0, TreeChangeTypes.RefreshAll) }, out var draftChanged, out var publishedChanged); - NotifyLocked(new[] { new MediaCacheRefresher.JsonPayload(0, TreeChangeTypes.RefreshAll) }, out var anythingChanged); + NotifyLocked(new[] { new ContentCacheRefresher.JsonPayload(0, null, TreeChangeTypes.RefreshAll) }, out var draftChanged, out var publishedChanged); + NotifyLocked(new[] { new MediaCacheRefresher.JsonPayload(0, null, TreeChangeTypes.RefreshAll) }, out var anythingChanged); } } @@ -888,14 +935,14 @@ namespace Umbraco.Web.PublishedCache.NuCache using (var scope = _scopeProvider.CreateScope()) { scope.ReadLock(Constants.Locks.ContentTree); - _contentStore.UpdateDataTypes(idsA, id => CreateContentType(PublishedItemType.Content, id)); + _contentStore.UpdateDataTypesLocked(idsA, id => CreateContentType(PublishedItemType.Content, id)); scope.Complete(); } using (var scope = _scopeProvider.CreateScope()) { scope.ReadLock(Constants.Locks.MediaTree); - _mediaStore.UpdateDataTypes(idsA, id => CreateContentType(PublishedItemType.Media, id)); + _mediaStore.UpdateDataTypesLocked(idsA, id => CreateContentType(PublishedItemType.Media, id)); scope.Complete(); } } @@ -925,7 +972,7 @@ namespace Umbraco.Web.PublishedCache.NuCache } break; case DomainChangeTypes.Remove: - _domainStore.Clear(payload.Id); + _domainStore.ClearLocked(payload.Id); break; case DomainChangeTypes.Refresh: var domain = _serviceContext.DomainService.GetById(payload.Id); @@ -933,14 +980,14 @@ namespace Umbraco.Web.PublishedCache.NuCache if (domain.RootContentId.HasValue == false) continue; // anomaly if (domain.LanguageIsoCode.IsNullOrWhiteSpace()) continue; // anomaly var culture = CultureInfo.GetCultureInfo(domain.LanguageIsoCode); - _domainStore.Set(domain.Id, new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard)); + _domainStore.SetLocked(domain.Id, new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard)); break; } } } } - //Methods used to prevent allocations of lists + //Methods used to prevent allocations of lists private void AddToList(ref List list, int val) => GetOrCreateList(ref list).Add(val); private List GetOrCreateList(ref List list) => list ?? (list = new List()); @@ -1018,11 +1065,11 @@ namespace Umbraco.Web.PublishedCache.NuCache ? Array.Empty() : _dataSource.GetTypeContentSources(scope, refreshedIds).ToArray(); - _contentStore.UpdateContentTypes(removedIds, typesA, kits); + _contentStore.UpdateContentTypesLocked(removedIds, typesA, kits); if (!otherIds.IsCollectionEmpty()) - _contentStore.UpdateContentTypes(CreateContentTypes(PublishedItemType.Content, otherIds.ToArray())); + _contentStore.UpdateContentTypesLocked(CreateContentTypes(PublishedItemType.Content, otherIds.ToArray())); if (!newIds.IsCollectionEmpty()) - _contentStore.NewContentTypes(CreateContentTypes(PublishedItemType.Content, newIds.ToArray())); + _contentStore.NewContentTypesLocked(CreateContentTypes(PublishedItemType.Content, newIds.ToArray())); scope.Complete(); } } @@ -1049,11 +1096,11 @@ namespace Umbraco.Web.PublishedCache.NuCache ? Array.Empty() : _dataSource.GetTypeMediaSources(scope, refreshedIds).ToArray(); - _mediaStore.UpdateContentTypes(removedIds, typesA, kits); + _mediaStore.UpdateContentTypesLocked(removedIds, typesA, kits); if (!otherIds.IsCollectionEmpty()) - _mediaStore.UpdateContentTypes(CreateContentTypes(PublishedItemType.Media, otherIds.ToArray()).ToArray()); + _mediaStore.UpdateContentTypesLocked(CreateContentTypes(PublishedItemType.Media, otherIds.ToArray()).ToArray()); if (!newIds.IsCollectionEmpty()) - _mediaStore.NewContentTypes(CreateContentTypes(PublishedItemType.Media, newIds.ToArray()).ToArray()); + _mediaStore.NewContentTypesLocked(CreateContentTypes(PublishedItemType.Media, newIds.ToArray()).ToArray()); scope.Complete(); } } @@ -1093,7 +1140,13 @@ namespace Umbraco.Web.PublishedCache.NuCache ContentStore.Snapshot contentSnap, mediaSnap; SnapDictionary.Snapshot domainSnap; IAppCache elementsCache; - lock (_storesLock) + + // Here we are reading/writing to shared objects so we need to lock (can't be _storesLock which manages the actual nucache files + // and would result in a deadlock). Even though we are locking around underlying readlocks (within CreateSnapshot) it's because + // we need to ensure that the result of contentSnap.Gen (etc) and the re-assignment of these values and _elements cache + // are done atomically. + + lock (_elementsLock) { var scopeContext = _scopeProvider.Context; @@ -1118,12 +1171,14 @@ namespace Umbraco.Web.PublishedCache.NuCache // elements // just need to make sure nothing gets elements in another enlisted action... so using // a MaxValue to make sure this one runs last, and it should be ok + scopeContext.Enlist("Umbraco.Web.PublishedCache.NuCache.PublishedSnapshotService.Resync", () => this, (completed, svc) => { ((PublishedSnapshot)svc.CurrentPublishedSnapshot)?.Resync(); }, int.MaxValue); } + // create a new snapshot cache if snapshots are different gens if (contentSnap.Gen != _contentGen || mediaSnap.Gen != _mediaGen || domainSnap.Gen != _domainGen || _elementsCache == null) { @@ -1281,6 +1336,21 @@ namespace Umbraco.Web.PublishedCache.NuCache RebuildMemberDbCache(contentTypeIds: memberTypeIds); } + /// + /// If a is ever saved with a different culture, we need to rebuild all of the content nucache table + /// + /// + /// + private void OnLanguageSaved(ILocalizationService sender, Core.Events.SaveEventArgs e) + { + //culture changed on an existing language + var cultureChanged = e.SavedEntities.Any(x => !x.WasPropertyDirty(nameof(ILanguage.Id)) && x.WasPropertyDirty(nameof(ILanguage.IsoCode))); + if (cultureChanged) + { + RebuildContentDbCache(); + } + } + private ContentNuDto GetDto(IContentBase content, bool published) { // should inject these in ctor @@ -1709,5 +1779,12 @@ AND cmsContentNu.nodeId IS NULL } #endregion + + #region Internals/Testing + + internal ContentStore GetContentStore() => _contentStore; + internal ContentStore GetMediaStore() => _mediaStore; + + #endregion } } diff --git a/src/Umbraco.Web/PublishedCache/NuCache/Snap/LinkedNode.cs b/src/Umbraco.Web/PublishedCache/NuCache/Snap/LinkedNode.cs index 78e0ec8d33..d187996df8 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/Snap/LinkedNode.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/Snap/LinkedNode.cs @@ -1,5 +1,11 @@ namespace Umbraco.Web.PublishedCache.NuCache.Snap { + //NOTE: This cannot be struct because it references itself + + /// + /// Used to represent an item in a linked list + /// + /// internal class LinkedNode where TValue : class { diff --git a/src/Umbraco.Web/PublishedCache/NuCache/SnapDictionary.cs b/src/Umbraco.Web/PublishedCache/NuCache/SnapDictionary.cs index c5b1df1206..c38940da25 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/SnapDictionary.cs +++ b/src/Umbraco.Web/PublishedCache/NuCache/SnapDictionary.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Umbraco.Core.Exceptions; using Umbraco.Core.Scoping; using Umbraco.Web.PublishedCache.NuCache.Snap; @@ -21,6 +22,11 @@ namespace Umbraco.Web.PublishedCache.NuCache // This class is optimized for many readers, few writers // Readers are lock-free + // NOTE - we used to lock _rlocko the long hand way with Monitor.Enter(_rlocko, ref lockTaken) but this has + // been replaced with a normal c# lock because that's exactly how the normal c# lock works, + // see https://blogs.msdn.microsoft.com/ericlippert/2009/03/06/locks-and-exceptions-do-not-mix/ + // for the readlock, there's no reason here to use the long hand way. + private readonly ConcurrentDictionary> _items; private readonly ConcurrentQueue _genObjs; private GenObj _genObj; @@ -29,7 +35,6 @@ namespace Umbraco.Web.PublishedCache.NuCache private long _liveGen, _floorGen; private bool _nextGen, _collectAuto; private Task _collectTask; - private volatile int _wlocked; // minGenDelta to be adjusted // we may want to throttle collects even if delta is reached @@ -70,20 +75,14 @@ namespace Umbraco.Web.PublishedCache.NuCache // are all ignored - Release is private and meant to be invoked with 'commit' being false only // only on the outermost lock (by SnapDictionaryWriter) - // using (...) {} for locking is prone to nasty leaks in case of weird exceptions - // such as thread-abort or out-of-memory, but let's not worry about it now + // side note - using (...) {} for locking is prone to nasty leaks in case of weird exceptions + // such as thread-abort or out-of-memory, which is why we've moved away from the old using wrapper we had on locking. private readonly string _instanceId = Guid.NewGuid().ToString("N"); - private class ReadLockInfo - { - public bool Taken; - } - private class WriteLockInfo { public bool Taken; - public bool Count; } // a scope contextual that represents a locked writer to the dictionary @@ -91,8 +90,7 @@ namespace Umbraco.Web.PublishedCache.NuCache { private readonly WriteLockInfo _lockinfo = new WriteLockInfo(); private readonly SnapDictionary _dictionary; - private int _released; - + public ScopedWriteLock(SnapDictionary dictionary, bool scoped) { _dictionary = dictionary; @@ -101,8 +99,6 @@ namespace Umbraco.Web.PublishedCache.NuCache public override void Release(bool completed) { - if (Interlocked.CompareExchange(ref _released, 1, 0) != 0) - return; _dictionary.Release(_lockinfo, completed); } } @@ -116,28 +112,31 @@ namespace Umbraco.Web.PublishedCache.NuCache return ScopeContextualBase.Get(scopeProvider, _instanceId, scoped => new ScopedWriteLock(this, scoped)); } + private void EnsureLocked() + { + if (!Monitor.IsEntered(_wlocko)) + throw new InvalidOperationException("Write lock must be acquried."); + } + private void Lock(WriteLockInfo lockInfo, bool forceGen = false) { + if (Monitor.IsEntered(_wlocko)) + throw new InvalidOperationException("Recursive locks not allowed"); + Monitor.Enter(_wlocko, ref lockInfo.Taken); - var rtaken = false; - try + lock(_rlocko) { - Monitor.Enter(_rlocko, ref rtaken); - // assume everything in finally runs atomically // http://stackoverflow.com/questions/18501678/can-this-unexpected-behavior-of-prepareconstrainedregions-and-thread-abort-be-ex // http://joeduffyblog.com/2005/03/18/atomicity-and-asynchronous-exception-failures/ // http://joeduffyblog.com/2007/02/07/introducing-the-new-readerwriterlockslim-in-orcas/ // http://chabster.blogspot.fr/2013/12/readerwriterlockslim-fails-on-dual.html //RuntimeHelpers.PrepareConstrainedRegions(); - try { } finally + try { } + finally { - // increment the lock count, and register that this lock is counting - _wlocked++; - lockInfo.Count = true; - - if (_nextGen == false || (forceGen && _wlocked == 1)) + if (_nextGen == false || (forceGen)) { // because we are changing things, a new generation // is created, which will trigger a new snapshot @@ -148,15 +147,6 @@ namespace Umbraco.Web.PublishedCache.NuCache } } } - finally - { - if (rtaken) Monitor.Exit(_rlocko); - } - } - - private void Lock(ReadLockInfo lockInfo) - { - Monitor.Enter(_rlocko, ref lockInfo.Taken); } private void Release(WriteLockInfo lockInfo, bool commit = true) @@ -167,22 +157,17 @@ namespace Umbraco.Web.PublishedCache.NuCache if (commit == false) { - var rtaken = false; - try + lock(_rlocko) { - Monitor.Enter(_rlocko, ref rtaken); - try { } finally + try { } + finally { // forget about the temp. liveGen _nextGen = false; _liveGen -= 1; } } - finally - { - if (rtaken) Monitor.Exit(_rlocko); - } - + foreach (var item in _items) { var link = item.Value; @@ -196,16 +181,10 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - // decrement the lock count, if counting, then exit the lock - if (lockInfo.Count) _wlocked--; + // TODO: Shouldn't this be in a finally block? Monitor.Exit(_wlocko); } - private void Release(ReadLockInfo lockInfo) - { - if (lockInfo.Taken) Monitor.Exit(_rlocko); - } - #endregion #region Set, Clear, Get, Has @@ -218,75 +197,59 @@ namespace Umbraco.Web.PublishedCache.NuCache return link; } - public void Set(TKey key, TValue value) + public void SetLocked(TKey key, TValue value) { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); + EnsureLocked(); - // this is safe only because we're write-locked - var link = GetHead(key); - if (link != null) + // this is safe only because we're write-locked + var link = GetHead(key); + if (link != null) + { + // already in the dict + if (link.Gen != _liveGen) { - // already in the dict - if (link.Gen != _liveGen) - { - // for an older gen - if value is different then insert a new - // link for the new gen, with the new value - if (link.Value != value) - _items.TryUpdate(key, new LinkedNode(value, _liveGen, link), link); - } - else - { - // for the live gen - we can fix the live gen - and remove it - // if value is null and there's no next gen - if (value == null && link.Next == null) - _items.TryRemove(key, out link); - else - link.Value = value; - } + // for an older gen - if value is different then insert a new + // link for the new gen, with the new value + if (link.Value != value) + _items.TryUpdate(key, new LinkedNode(value, _liveGen, link), link); } else { - _items.TryAdd(key, new LinkedNode(value, _liveGen)); - } - } - finally - { - Release(lockInfo); - } - } - - public void Clear(TKey key) - { - Set(key, null); - } - - public void Clear() - { - var lockInfo = new WriteLockInfo(); - try - { - Lock(lockInfo); - - // this is safe only because we're write-locked - foreach (var kvp in _items.Where(x => x.Value != null)) - { - if (kvp.Value.Gen < _liveGen) - { - var link = new LinkedNode(null, _liveGen, kvp.Value); - _items.TryUpdate(kvp.Key, link, kvp.Value); - } + // for the live gen - we can fix the live gen - and remove it + // if value is null and there's no next gen + if (value == null && link.Next == null) + _items.TryRemove(key, out link); else - { - kvp.Value.Value = null; - } + link.Value = value; } } - finally + else { - Release(lockInfo); + _items.TryAdd(key, new LinkedNode(value, _liveGen)); + } + } + + public void ClearLocked(TKey key) + { + SetLocked(key, null); + } + + public void ClearLocked() + { + EnsureLocked(); + + // this is safe only because we're write-locked + foreach (var kvp in _items.Where(x => x.Value != null)) + { + if (kvp.Value.Gen < _liveGen) + { + var link = new LinkedNode(null, _liveGen, kvp.Value); + _items.TryUpdate(kvp.Key, link, kvp.Value); + } + else + { + kvp.Value.Value = null; + } } } @@ -346,11 +309,8 @@ namespace Umbraco.Web.PublishedCache.NuCache public Snapshot CreateSnapshot() { - var lockInfo = new ReadLockInfo(); - try + lock(_rlocko) { - Lock(lockInfo); - // if no next generation is required, and we already have a gen object, // use it to create a new snapshot if (_nextGen == false && _genObj != null) @@ -359,7 +319,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // else we need to try to create a new gen object // whether we are wlocked or not, noone can rlock while we do, // so _liveGen and _nextGen are safe - if (_wlocked > 0) // volatile, cannot ++ but could -- + if (Monitor.IsEntered(_wlocko)) { // write-locked, cannot use latest gen (at least 1) so use previous var snapGen = _nextGen ? _liveGen - 1 : _liveGen; @@ -371,7 +331,7 @@ namespace Umbraco.Web.PublishedCache.NuCache // if we have one already, ensure it's consistent else if (_genObj.Gen != snapGen) - throw new Exception("panic"); + throw new PanicException($"The generation {_genObj.Gen} does not equal the snapshot generation {snapGen}"); } else { @@ -397,10 +357,6 @@ namespace Umbraco.Web.PublishedCache.NuCache return snapshot; } - finally - { - Release(lockInfo); - } } public Task CollectAsync() @@ -495,17 +451,18 @@ namespace Umbraco.Web.PublishedCache.NuCache } } - public /*async*/ Task PendingCollect() - { - Task task; - lock (_rlocko) - { - task = _collectTask; - } - return task ?? Task.CompletedTask; - //if (task != null) - // await task; - } + // TODO: This is never used? Should it be? Maybe move to TestHelper below? + //public /*async*/ Task PendingCollect() + //{ + // Task task; + // lock (_rlocko) + // { + // task = _collectTask; + // } + // return task ?? Task.CompletedTask; + // //if (task != null) + // // await task; + //} public long GenCount => _genObjs.Count; @@ -530,7 +487,7 @@ namespace Umbraco.Web.PublishedCache.NuCache public long LiveGen => _dict._liveGen; public long FloorGen => _dict._floorGen; public bool NextGen => _dict._nextGen; - public int WLocked => _dict._wlocked; + public bool IsLocked => Monitor.IsEntered(_dict._wlocko); public bool CollectAuto { diff --git a/src/Umbraco.Web/PublishedCache/NuCache/notes.txt b/src/Umbraco.Web/PublishedCache/NuCache/readme.md similarity index 70% rename from src/Umbraco.Web/PublishedCache/NuCache/notes.txt rename to src/Umbraco.Web/PublishedCache/NuCache/readme.md index ff2d8dd48b..c8e8a363e9 100644 --- a/src/Umbraco.Web/PublishedCache/NuCache/notes.txt +++ b/src/Umbraco.Web/PublishedCache/NuCache/readme.md @@ -1,10 +1,6 @@ NuCache Documentation ====================== -NOTE - RENAMING -Facade = PublishedSnapshot -and everything needs to be renamed accordingly - HOW IT WORKS ------------- @@ -22,12 +18,12 @@ When reading the cache, we read views up the chain until we find a value (which null) for the given id, and finally we read the store itself. -The FacadeService manages a ContentStore for content, and another for media. -When a Facade is created, the FacadeService gets ContentView objects from the stores. +The PublishedSnapshotService manages a ContentStore for content, and another for media. +When a PublishedSnapshot is created, the PublishedSnapshotService gets ContentView objects from the stores. Views provide an immutable snapshot of the content and media. -When the FacadeService is notified of changes, it notifies the stores. -Then it resync the current Facade, so that it requires new views, etc. +When the PublishedSnapshotService is notified of changes, it notifies the stores. +Then it resync the current PublishedSnapshot, so that it requires new views, etc. Whenever a content, media or member is modified or removed, the cmsContentNu table is updated with a json dictionary of alias => property value, so that a content, @@ -50,32 +46,32 @@ Each ContentStore has a _freezeLock object used to protect the 'Frozen' state of the store. It's a disposable object that releases the lock when disposed, so usage would be: using (store.Frozen) { ... }. -The FacadeService has a _storesLock object used to guarantee atomic access to the +The PublishedSnapshotService has a _storesLock object used to guarantee atomic access to the set of content, media stores. CACHE ------ -For each set of views, the FacadeService creates a SnapshotCache. So a SnapshotCache +For each set of views, the PublishedSnapshotService creates a SnapshotCache. So a SnapshotCache is valid until anything changes in the content or media trees. In other words, things that go in the SnapshotCache stay until a content or media is modified. -For each Facade, the FacadeService creates a FacadeCache. So a FacadeCache is valid -for the duration of the Facade (usually, the request). In other words, things that go -in the FacadeCache stay (and are visible to) for the duration of the request only. +For each PublishedSnapshot, the PublishedSnapshotService creates a PublishedSnapshotCache. So a PublishedSnapshotCache is valid +for the duration of the PublishedSnapshot (usually, the request). In other words, things that go +in the PublishedSnapshotCache stay (and are visible to) for the duration of the request only. -The FacadeService defines a static constant FullCacheWhenPreviewing, that defines +The PublishedSnapshotService defines a static constant FullCacheWhenPreviewing, that defines how caches operate when previewing: - when true, the caches in preview mode work normally. -- when false, everything that would go to the SnapshotCache goes to the FacadeCache. +- when false, everything that would go to the SnapshotCache goes to the PublishedSnapshotCache. At the moment it is true in the code, which means that eg converted values for previewed content will go in the SnapshotCache. Makes for faster preview, but uses more memory on the long term... would need some benchmarking to figure out what is best. -Members only live for the duration of the Facade. So, for members SnapshotCache is -never used, and everything goes to the FacadeCache. +Members only live for the duration of the PublishedSnapshot. So, for members SnapshotCache is +never used, and everything goes to the PublishedSnapshotCache. All cache keys are computed in the CacheKeys static class. @@ -85,15 +81,15 @@ TESTS For testing purposes the following mechanisms exist: -The Facade type has a static Current property that is used to obtain the 'current' -facade in many places, going through the PublishedCachesServiceResolver to get the -current service, and asking the current service for the current facade, which by +The PublishedSnapshot type has a static Current property that is used to obtain the 'current' +PublishedSnapshot in many places, going through the PublishedCachesServiceResolver to get the +current service, and asking the current service for the current PublishedSnapshot, which by default relies on UmbracoContext. For test purposes, it is possible to override the -entire mechanism by defining Facade.GetCurrentFacadeFunc which should return a facade. +entire mechanism by defining PublishedSnapshot.GetCurrentPublishedSnapshotFunc which should return a PublishedSnapshot. A PublishedContent keeps only id-references to its parent and children, and needs a way to retrieve the actual objects from the cache - which depends on the current -facade. It is possible to override the entire mechanism by defining PublishedContent. +PublishedSnapshot. It is possible to override the entire mechanism by defining PublishedContent. GetContentByIdFunc or .GetMediaByIdFunc. Setting these functions must be done before Resolution is frozen. @@ -110,7 +106,7 @@ possible to support detached contents & properties, even those that do not have int id, but again this should be refactored entirely anyway. Not doing any row-version checks (see XmlStore) when reloading from database, though it -is maintained in the database. Two FIXME in FacadeService. Should we do it? +is maintained in the database. Two FIXME in PublishedSnapshotService. Should we do it? There is no on-disk cache at all so everything is reloaded from the cmsContentNu table when the site restarts. This is pretty fast, but we should experiment with solutions to @@ -121,4 +117,4 @@ PublishedMember exposes properties that IPublishedContent does not, and that are to be lost soon as the member is wrapped (content set, model...) - so we probably need some sort of IPublishedMember. -/ \ No newline at end of file +/ diff --git a/src/Umbraco.Web/PublishedContentExtensions.cs b/src/Umbraco.Web/PublishedContentExtensions.cs index d7283a1e90..061422859c 100644 --- a/src/Umbraco.Web/PublishedContentExtensions.cs +++ b/src/Umbraco.Web/PublishedContentExtensions.cs @@ -38,7 +38,7 @@ namespace Umbraco.Web /// A value indicating whether the content is of a content type composed of a content type identified by the alias. public static bool IsComposedOf(this IPublishedContent content, string alias) { - return content.ContentType.CompositionAliases.Contains(alias); + return content.ContentType.CompositionAliases.InvariantContains(alias); } #endregion @@ -421,7 +421,7 @@ namespace Umbraco.Web /// Does not consider the content itself. Returns all ancestors, of the specified content type. public static IEnumerable Ancestors(this IPublishedContent content, string contentTypeAlias) { - return content.AncestorsOrSelf(false, n => n.ContentType.Alias == contentTypeAlias); + return content.AncestorsOrSelf(false, n => n.ContentType.Alias.InvariantEquals(contentTypeAlias)); } /// @@ -486,7 +486,7 @@ namespace Umbraco.Web /// May or may not begin with the content itself, depending on its content type. public static IEnumerable AncestorsOrSelf(this IPublishedContent content, string contentTypeAlias) { - return content.AncestorsOrSelf(true, n => n.ContentType.Alias == contentTypeAlias); + return content.AncestorsOrSelf(true, n => n.ContentType.Alias.InvariantEquals(contentTypeAlias)); } /// @@ -549,7 +549,7 @@ namespace Umbraco.Web /// Does not consider the content itself. May return null. public static IPublishedContent Ancestor(this IPublishedContent content, string contentTypeAlias) { - return content.EnumerateAncestors(false).FirstOrDefault(x => x.ContentType.Alias == contentTypeAlias); + return content.EnumerateAncestors(false).FirstOrDefault(x => x.ContentType.Alias.InvariantEquals(contentTypeAlias)); } /// @@ -612,7 +612,7 @@ namespace Umbraco.Web /// May or may not return the content itself depending on its content type. May return null. public static IPublishedContent AncestorOrSelf(this IPublishedContent content, string contentTypeAlias) { - return content.EnumerateAncestors(true).FirstOrDefault(x => x.ContentType.Alias == contentTypeAlias); + return content.EnumerateAncestors(true).FirstOrDefault(x => x.ContentType.Alias.InvariantEquals(contentTypeAlias)); } /// @@ -727,7 +727,7 @@ namespace Umbraco.Web public static IEnumerable DescendantsOfType(this IPublishedContent content, string contentTypeAlias, string culture = null) { - return content.DescendantsOrSelf(false, p => p.ContentType.Alias == contentTypeAlias, culture); + return content.DescendantsOrSelf(false, p => p.ContentType.Alias.InvariantEquals(contentTypeAlias), culture); } public static IEnumerable Descendants(this IPublishedContent content, string culture = null) @@ -754,7 +754,7 @@ namespace Umbraco.Web public static IEnumerable DescendantsOrSelfOfType(this IPublishedContent content, string contentTypeAlias, string culture = null) { - return content.DescendantsOrSelf(true, p => p.ContentType.Alias == contentTypeAlias, culture); + return content.DescendantsOrSelf(true, p => p.ContentType.Alias.InvariantEquals(contentTypeAlias), culture); } public static IEnumerable DescendantsOrSelf(this IPublishedContent content, string culture = null) @@ -781,7 +781,7 @@ namespace Umbraco.Web public static IPublishedContent DescendantOfType(this IPublishedContent content, string contentTypeAlias, string culture = null) { - return content.EnumerateDescendants(false, culture).FirstOrDefault(x => x.ContentType.Alias == contentTypeAlias); + return content.EnumerateDescendants(false, culture).FirstOrDefault(x => x.ContentType.Alias.InvariantEquals(contentTypeAlias)); } public static T Descendant(this IPublishedContent content, string culture = null) @@ -808,7 +808,7 @@ namespace Umbraco.Web public static IPublishedContent DescendantOrSelfOfType(this IPublishedContent content, string contentTypeAlias, string culture = null) { - return content.EnumerateDescendants(true, culture).FirstOrDefault(x => x.ContentType.Alias == contentTypeAlias); + return content.EnumerateDescendants(true, culture).FirstOrDefault(x => x.ContentType.Alias.InvariantEquals(contentTypeAlias)); } public static T DescendantOrSelf(this IPublishedContent content, string culture = null) @@ -892,7 +892,7 @@ namespace Umbraco.Web /// The children of the content, of any of the specified types. public static IEnumerable ChildrenOfType(this IPublishedContent content, string contentTypeAlias, string culture = null) { - return content.Children(x => contentTypeAlias.InvariantContains(x.ContentType.Alias), culture); + return content.Children(x => x.ContentType.Alias.InvariantEquals(contentTypeAlias), culture); } /// @@ -973,7 +973,7 @@ namespace Umbraco.Web ? content.Children(culture).Any() ? content.Children(culture).ElementAt(0) : null - : content.Children(culture).FirstOrDefault(x => x.ContentType.Alias == contentTypeAliasFilter); + : content.Children(culture).FirstOrDefault(x => x.ContentType.Alias.InvariantEquals(contentTypeAliasFilter)); if (firstNode == null) return new DataTable(); //no children found @@ -993,7 +993,7 @@ namespace Umbraco.Web { if (contentTypeAliasFilter.IsNullOrWhiteSpace() == false) { - if (n.ContentType.Alias != contentTypeAliasFilter) + if (n.ContentType.Alias.InvariantEquals(contentTypeAliasFilter) == false) continue; //skip this one, it doesn't match the filter } @@ -1067,7 +1067,7 @@ namespace Umbraco.Web /// /// Note that in V7 this method also return the content node self. /// - public static IEnumerable Siblings(this IPublishedContent content, string culture = null) + public static IEnumerable Siblings(this IPublishedContent content, string culture = null) where T : class, IPublishedContent { return SiblingsAndSelf(content, culture).Where(x => x.Id != content.Id); @@ -1193,7 +1193,7 @@ namespace Umbraco.Web /// if any. In addition, when the content type is multi-lingual, this is the url for the /// specified culture. Otherwise, it is the invariant url. /// - public static string Url(this IPublishedContent content, string culture = null, UrlMode mode = UrlMode.Auto) + public static string Url(this IPublishedContent content, string culture = null, UrlMode mode = UrlMode.Default) { var umbracoContext = Composing.Current.UmbracoContext; diff --git a/src/Umbraco.Web/PublishedContentQuery.cs b/src/Umbraco.Web/PublishedContentQuery.cs index 2dbe4de4c5..d697898f33 100644 --- a/src/Umbraco.Web/PublishedContentQuery.cs +++ b/src/Umbraco.Web/PublishedContentQuery.cs @@ -183,54 +183,62 @@ namespace Umbraco.Web #region Search /// - public IEnumerable Search(string term, string culture = "*", string indexName = null) + public IEnumerable Search(string term, string culture = "*", string indexName = Constants.UmbracoIndexes.ExternalIndexName) { return Search(term, 0, 0, out _, culture, indexName); } /// - public IEnumerable Search(string term, int skip, int take, out long totalRecords, string culture = "*", string indexName = null) + public IEnumerable Search(string term, int skip, int take, out long totalRecords, string culture = "*", string indexName = Constants.UmbracoIndexes.ExternalIndexName) { - indexName = string.IsNullOrEmpty(indexName) - ? Constants.UmbracoIndexes.ExternalIndexName - : indexName; + if (skip < 0) + { + throw new ArgumentOutOfRangeException(nameof(skip), skip, "The value must be greater than or equal to zero."); + } + + if (take < 0) + { + throw new ArgumentOutOfRangeException(nameof(take), take, "The value must be greater than or equal to zero."); + } + + if (string.IsNullOrEmpty(indexName)) + { + indexName = Constants.UmbracoIndexes.ExternalIndexName; + } if (!_examineManager.TryGetIndex(indexName, out var index) || !(index is IUmbracoIndex umbIndex)) + { throw new InvalidOperationException($"No index found by name {indexName} or is not of type {typeof(IUmbracoIndex)}"); + } - var searcher = umbIndex.GetSearcher(); + var query = umbIndex.GetSearcher().CreateQuery(IndexTypes.Content); - // default to max 500 results - var count = skip == 0 && take == 0 ? 500 : skip + take; - - ISearchResults results; + IQueryExecutor queryExecutor; if (culture == "*") { - //search everything - - results = searcher.Search(term, count); + // Search everything + queryExecutor = query.ManagedQuery(term); } - else if (culture.IsNullOrWhiteSpace()) + else if (string.IsNullOrWhiteSpace(culture)) { - //only search invariant - - var qry = searcher.CreateQuery().Field(UmbracoContentIndex.VariesByCultureFieldName, "n"); //must not vary by culture - qry = qry.And().ManagedQuery(term); - results = qry.Execute(count); + // Only search invariant + queryExecutor = query.Field(UmbracoContentIndex.VariesByCultureFieldName, "n") // Must not vary by culture + .And().ManagedQuery(term); } else { - //search only the specified culture - - //get all index fields suffixed with the culture name supplied - var cultureFields = umbIndex.GetCultureAndInvariantFields(culture).ToArray(); - var qry = searcher.CreateQuery().ManagedQuery(term, cultureFields); - results = qry.Execute(count); + // Only search the specified culture + var fields = umbIndex.GetCultureAndInvariantFields(culture).ToArray(); // Get all index fields suffixed with the culture name supplied + queryExecutor = query.ManagedQuery(term, fields); } + var results = skip == 0 && take == 0 + ? queryExecutor.Execute() + : queryExecutor.Execute(skip + take); + totalRecords = results.TotalItemCount; - return new CultureContextualSearchResults(results.ToPublishedSearchResults(_publishedSnapshot.Content), _variationContextAccessor, culture); + return new CultureContextualSearchResults(results.Skip(skip).ToPublishedSearchResults(_publishedSnapshot.Content), _variationContextAccessor, culture); } /// @@ -242,12 +250,23 @@ namespace Umbraco.Web /// public IEnumerable Search(IQueryExecutor query, int skip, int take, out long totalRecords) { + if (skip < 0) + { + throw new ArgumentOutOfRangeException(nameof(skip), skip, "The value must be greater than or equal to zero."); + } + + if (take < 0) + { + throw new ArgumentOutOfRangeException(nameof(take), take, "The value must be greater than or equal to zero."); + } + var results = skip == 0 && take == 0 ? query.Execute() - : query.Execute(maxResults: skip + take); + : query.Execute(skip + take); totalRecords = results.TotalItemCount; - return results.ToPublishedSearchResults(_publishedSnapshot.Content); + + return results.Skip(skip).ToPublishedSearchResults(_publishedSnapshot); } /// @@ -320,9 +339,6 @@ namespace Umbraco.Web } } - - - #endregion } } diff --git a/src/Umbraco.Web/PublishedElementExtensions.cs b/src/Umbraco.Web/PublishedElementExtensions.cs index c35c85c606..f28581eb42 100644 --- a/src/Umbraco.Web/PublishedElementExtensions.cs +++ b/src/Umbraco.Web/PublishedElementExtensions.cs @@ -36,7 +36,7 @@ namespace Umbraco.Web /// A value indicating whether the content is of a content type composed of a content type identified by the alias. public static bool IsComposedOf(this IPublishedElement content, string alias) { - return content.ContentType.CompositionAliases.Contains(alias); + return content.ContentType.CompositionAliases.InvariantContains(alias); } #endregion @@ -165,8 +165,9 @@ namespace Umbraco.Web public static IEnumerable OfTypes(this IEnumerable contents, params string[] types) where T : IPublishedElement { - types = types.Select(x => x.ToLowerInvariant()).ToArray(); - return contents.Where(x => types.Contains(x.ContentType.Alias.ToLowerInvariant())); + if (types == null || types.Length == 0) return Enumerable.Empty(); + + return contents.Where(x => types.InvariantContains(x.ContentType.Alias)); } #endregion diff --git a/src/Umbraco.Web/RoutableDocumentFilter.cs b/src/Umbraco.Web/RoutableDocumentFilter.cs new file mode 100644 index 0000000000..9725ce0e14 --- /dev/null +++ b/src/Umbraco.Web/RoutableDocumentFilter.cs @@ -0,0 +1,207 @@ +using System; +using System.IO; +using System.Web; +using System.Web.Routing; +using Umbraco.Core; +using Umbraco.Core.Logging; +using Umbraco.Core.Configuration; +using System.Threading; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Core.IO; +using System.Collections.Concurrent; +using Umbraco.Core.Collections; + +namespace Umbraco.Web +{ + /// + /// Utility class used to check if the current request is for a front-end request + /// + /// + /// There are various checks to determine if this is a front-end request such as checking if the request is part of any reserved paths or existing MVC routes. + /// + public sealed class RoutableDocumentFilter + { + public RoutableDocumentFilter(IGlobalSettings globalSettings) + { + _globalSettings = globalSettings; + } + + private static readonly ConcurrentDictionary RouteChecks = new ConcurrentDictionary(); + private readonly IGlobalSettings _globalSettings; + private object _locker = new object(); + private bool _isInit = false; + private int? _routeCount; + private HashSet _reservedList; + + /// + /// Checks if the request is a document request (i.e. one that the module should handle) + /// + /// + /// + /// + public bool IsDocumentRequest(HttpContextBase httpContext, Uri uri) + { + var maybeDoc = true; + var lpath = uri.AbsolutePath.ToLowerInvariant(); + + // handle directory-urls used for asmx + // TODO: legacy - what's the point really? + var asmxPos = lpath.IndexOf(".asmx/", StringComparison.OrdinalIgnoreCase); + if (asmxPos >= 0) + { + // use uri.AbsolutePath, not path, 'cos path has been lowercased + httpContext.RewritePath(uri.AbsolutePath.Substring(0, asmxPos + 5), // filePath + uri.AbsolutePath.Substring(asmxPos + 5), // pathInfo + uri.Query.TrimStart('?')); + maybeDoc = false; + } + + // a document request should be + // /foo/bar/nil + // /foo/bar/nil/ + // /foo/bar/nil.aspx + // where /foo is not a reserved path + + // if the path contains an extension that is not .aspx + // then it cannot be a document request + var extension = Path.GetExtension(lpath); + if (maybeDoc && extension.IsNullOrWhiteSpace() == false && extension != ".aspx") + maybeDoc = false; + + // at that point, either we have no extension, or it is .aspx + + // if the path is reserved then it cannot be a document request + if (maybeDoc && IsReservedPathOrUrl(lpath, httpContext, RouteTable.Routes)) + maybeDoc = false; + + //NOTE: No need to warn, plus if we do we should log the document, as this message doesn't really tell us anything :) + //if (!maybeDoc) + //{ + // Logger.Warn("Not a document"); + //} + return maybeDoc; + } + + /// + /// Determines whether the specified URL is reserved or is inside a reserved path. + /// + /// The URL to check. + /// + /// true if the specified URL is reserved; otherwise, false. + /// + internal bool IsReservedPathOrUrl(string url) + { + LazyInitializer.EnsureInitialized(ref _reservedList, ref _isInit, ref _locker, () => + { + // store references to strings to determine changes + var reservedPathsCache = _globalSettings.ReservedPaths; + var reservedUrlsCache = _globalSettings.ReservedUrls; + + // add URLs and paths to a new list + var newReservedList = new HashSet(); + foreach (var reservedUrlTrimmed in reservedUrlsCache + .Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim().ToLowerInvariant()) + .Where(x => x.IsNullOrWhiteSpace() == false) + .Select(reservedUrl => IOHelper.ResolveUrl(reservedUrl).Trim().EnsureStartsWith("/")) + .Where(reservedUrlTrimmed => reservedUrlTrimmed.IsNullOrWhiteSpace() == false)) + { + newReservedList.Add(reservedUrlTrimmed); + } + + foreach (var reservedPathTrimmed in NormalizePaths(reservedPathsCache.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries))) + { + newReservedList.Add(reservedPathTrimmed); + } + + foreach (var reservedPathTrimmed in NormalizePaths(ReservedPaths)) + { + newReservedList.Add(reservedPathTrimmed); + } + + // use the new list from now on + return newReservedList; + }); + + //The url should be cleaned up before checking: + // * If it doesn't contain an '.' in the path then we assume it is a path based URL, if that is the case we should add an trailing '/' because all of our reservedPaths use a trailing '/' + // * We shouldn't be comparing the query at all + var pathPart = url.Split(new[] { '?' }, StringSplitOptions.RemoveEmptyEntries)[0].ToLowerInvariant(); + if (pathPart.Contains(".") == false) + { + pathPart = pathPart.EnsureEndsWith('/'); + } + + // return true if url starts with an element of the reserved list + return _reservedList.Any(x => pathPart.InvariantStartsWith(x)); + } + + private IEnumerable NormalizePaths(IEnumerable paths) + { + return paths + .Select(x => x.Trim().ToLowerInvariant()) + .Where(x => x.IsNullOrWhiteSpace() == false) + .Select(reservedPath => IOHelper.ResolveUrl(reservedPath).Trim().EnsureStartsWith("/").EnsureEndsWith("/")) + .Where(reservedPathTrimmed => reservedPathTrimmed.IsNullOrWhiteSpace() == false); + } + + /// + /// Determines whether the current request is reserved based on the route table and + /// whether the specified URL is reserved or is inside a reserved path. + /// + /// + /// + /// The route collection to lookup the request in + /// + internal bool IsReservedPathOrUrl(string url, HttpContextBase httpContext, RouteCollection routes) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + if (routes == null) throw new ArgumentNullException(nameof(routes)); + + //This is some rudimentary code to check if the route table has changed at runtime, we're basically just keeping a count + //of the routes. This isn't fail safe but there's no way to monitor changes to the route table. Else we need to create a hash + //of all routes and then recompare but that will be annoying to do on each request and then we might as well just do the whole MVC + //route on each request like we were doing before instead of caching the result of GetRouteData. + var changed = false; + using (routes.GetReadLock()) + { + if (!_routeCount.HasValue || _routeCount.Value != routes.Count) + { + //the counts are not set or have changed, need to reset + changed = true; + } + } + if (changed) + { + using (routes.GetWriteLock()) + { + _routeCount = routes.Count; + + //try clearing each entry + foreach(var r in RouteChecks.Keys.ToList()) + RouteChecks.TryRemove(r, out _); + } + } + + var absPath = httpContext?.Request?.Url.AbsolutePath; + + if (absPath.IsNullOrWhiteSpace()) + return false; + + //check if the current request matches a route, if so then it is reserved. + var hasRoute = RouteChecks.GetOrAdd(absPath, x => routes.GetRouteData(httpContext) != null); + if (hasRoute) + return true; + + //continue with the standard ignore routine + return IsReservedPathOrUrl(url); + } + + /// + /// This is used internally to track any registered callback paths for Identity providers. If the request path matches + /// any of the registered paths, then the module will let the request keep executing + /// + internal static readonly ConcurrentHashSet ReservedPaths = new ConcurrentHashSet(); + } +} diff --git a/src/Umbraco.Web/Routing/AliasUrlProvider.cs b/src/Umbraco.Web/Routing/AliasUrlProvider.cs index efd48af2d4..f84ac608d2 100644 --- a/src/Umbraco.Web/Routing/AliasUrlProvider.cs +++ b/src/Umbraco.Web/Routing/AliasUrlProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; @@ -83,12 +84,17 @@ namespace Umbraco.Web.Routing yield break; var umbracoUrlName = node.Value(Constants.Conventions.Content.UrlAlias); - if (string.IsNullOrWhiteSpace(umbracoUrlName)) + var aliases = umbracoUrlName?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (aliases == null || aliases.Any() == false) yield break; - var path = "/" + umbracoUrlName; - var uri = new Uri(path, UriKind.Relative); - yield return UrlInfo.Url(UriUtility.UriFromUmbraco(uri, _globalSettings, _requestConfig).ToString()); + foreach (var alias in aliases.Distinct()) + { + var path = "/" + alias; + var uri = new Uri(path, UriKind.Relative); + yield return UrlInfo.Url(UriUtility.UriFromUmbraco(uri, _globalSettings, _requestConfig).ToString()); + } } else { @@ -105,12 +111,17 @@ namespace Umbraco.Web.Routing ? node.Value(Constants.Conventions.Content.UrlAlias, culture: domainUri.Culture.Name) : node.Value(Constants.Conventions.Content.UrlAlias); - if (string.IsNullOrWhiteSpace(umbracoUrlName)) + var aliases = umbracoUrlName?.Split(new [] {','}, StringSplitOptions.RemoveEmptyEntries); + + if (aliases == null || aliases.Any() == false) continue; - var path = "/" + umbracoUrlName; - var uri = new Uri(CombinePaths(domainUri.Uri.GetLeftPart(UriPartial.Path), path)); - yield return UrlInfo.Url(UriUtility.UriFromUmbraco(uri, _globalSettings, _requestConfig).ToString(), domainUri.Culture.Name); + foreach(var alias in aliases.Distinct()) + { + var path = "/" + alias; + var uri = new Uri(CombinePaths(domainUri.Uri.GetLeftPart(UriPartial.Path), path)); + yield return UrlInfo.Url(UriUtility.UriFromUmbraco(uri, _globalSettings, _requestConfig).ToString(), domainUri.Culture.Name); + } } } } diff --git a/src/Umbraco.Web/Routing/DefaultMediaUrlProvider.cs b/src/Umbraco.Web/Routing/DefaultMediaUrlProvider.cs index 02dc4ebf29..89abde0576 100644 --- a/src/Umbraco.Web/Routing/DefaultMediaUrlProvider.cs +++ b/src/Umbraco.Web/Routing/DefaultMediaUrlProvider.cs @@ -1,7 +1,7 @@ using System; -using Umbraco.Core; +using Umbraco.Core.Composing; using Umbraco.Core.Models.PublishedContent; -using Umbraco.Core.PropertyEditors.ValueConverters; +using Umbraco.Core.PropertyEditors; namespace Umbraco.Web.Routing { @@ -10,13 +10,26 @@ namespace Umbraco.Web.Routing /// public class DefaultMediaUrlProvider : IMediaUrlProvider { + private readonly PropertyEditorCollection _propertyEditors; + + public DefaultMediaUrlProvider(PropertyEditorCollection propertyEditors) + { + _propertyEditors = propertyEditors ?? throw new ArgumentNullException(nameof(propertyEditors)); + } + + [Obsolete("Use the constructor with all parameters instead")] + public DefaultMediaUrlProvider() : this(Current.PropertyEditors) + { + } + /// public virtual UrlInfo GetMediaUrl(UmbracoContext umbracoContext, IPublishedContent content, - string propertyAlias, - UrlMode mode, string culture, Uri current) + string propertyAlias, UrlMode mode, string culture, Uri current) { var prop = content.GetProperty(propertyAlias); - var value = prop?.GetValue(culture); + + // get the raw source value since this is what is used by IDataEditorWithMediaPath for processing + var value = prop?.GetSourceValue(culture); if (value == null) { return null; @@ -25,15 +38,10 @@ namespace Umbraco.Web.Routing var propType = prop.PropertyType; string path = null; - switch (propType.EditorAlias) + if (_propertyEditors.TryGet(propType.EditorAlias, out var editor) + && editor is IDataEditorWithMediaPath dataEditor) { - case Constants.PropertyEditors.Aliases.UploadField: - path = value.ToString(); - break; - case Constants.PropertyEditors.Aliases.ImageCropper: - //get the url from the json format - path = value is ImageCropperValue stronglyTyped ? stronglyTyped.Src : value.ToString(); - break; + path = dataEditor.GetMediaPath(value); } var url = AssembleUrl(path, current, mode); diff --git a/src/Umbraco.Web/Routing/RedirectTrackingComponent.cs b/src/Umbraco.Web/Routing/RedirectTrackingComponent.cs index 77aa3c65a1..fcf6f55f44 100644 --- a/src/Umbraco.Web/Routing/RedirectTrackingComponent.cs +++ b/src/Umbraco.Web/Routing/RedirectTrackingComponent.cs @@ -1,16 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; -using Umbraco.Core.Cache; using Umbraco.Core.Composing; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Events; using Umbraco.Core.Models; using Umbraco.Core.Services; using Umbraco.Core.Services.Implement; -using Umbraco.Core.Sync; -using Umbraco.Web.Cache; -using Current = Umbraco.Web.Composing.Current; +using Umbraco.Web.PublishedCache; namespace Umbraco.Web.Routing { @@ -23,64 +20,17 @@ namespace Umbraco.Web.Routing /// recycle bin = moving to and from does nothing: to = the node is gone, where would we redirect? from = same public sealed class RedirectTrackingComponent : IComponent { - private const string ContextKey1 = "Umbraco.Web.Redirects.RedirectTrackingEventHandler.1"; - private const string ContextKey2 = "Umbraco.Web.Redirects.RedirectTrackingEventHandler.2"; - private const string ContextKey3 = "Umbraco.Web.Redirects.RedirectTrackingEventHandler.3"; + private const string _eventStateKey = "Umbraco.Web.Redirects.RedirectTrackingEventHandler"; private readonly IUmbracoSettingsSection _umbracoSettings; + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly IRedirectUrlService _redirectUrlService; - public RedirectTrackingComponent(IUmbracoSettingsSection umbracoSettings) + public RedirectTrackingComponent(IUmbracoSettingsSection umbracoSettings, IPublishedSnapshotAccessor publishedSnapshotAccessor, IRedirectUrlService redirectUrlService) { _umbracoSettings = umbracoSettings; - } - - private static Dictionary OldRoutes - { - get - { - var oldRoutes = (Dictionary) Current.UmbracoContext.HttpContext.Items[ContextKey3]; - if (oldRoutes == null) - Current.UmbracoContext.HttpContext.Items[ContextKey3] = oldRoutes = new Dictionary(); - return oldRoutes; - } - } - - private static bool HasOldRoutes - { - get - { - if (Current.UmbracoContext == null) return false; - if (Current.UmbracoContext.HttpContext == null) return false; - if (Current.UmbracoContext.HttpContext.Items[ContextKey3] == null) return false; - return true; - } - } - - private static bool LockedEvents - { - get => Moving && Current.UmbracoContext.HttpContext.Items[ContextKey2] != null; - set - { - if (Moving && value) - Current.UmbracoContext.HttpContext.Items[ContextKey2] = true; - else - Current.UmbracoContext.HttpContext.Items.Remove(ContextKey2); - } - } - - private static bool Moving - { - get => Current.UmbracoContext.HttpContext.Items[ContextKey1] != null; - set - { - if (value) - Current.UmbracoContext.HttpContext.Items[ContextKey1] = true; - else - { - Current.UmbracoContext.HttpContext.Items.Remove(ContextKey1); - Current.UmbracoContext.HttpContext.Items.Remove(ContextKey2); - } - } + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _redirectUrlService = redirectUrlService; } public void Initialize() @@ -88,26 +38,11 @@ namespace Umbraco.Web.Routing // don't let the event handlers kick in if Redirect Tracking is turned off in the config if (_umbracoSettings.WebRouting.DisableRedirectUrlTracking) return; - // events are weird - // on 'published' we 'could' get the old or the new route depending on event handlers order - // so it is not reliable. getting the old route in 'publishing' to be sure and storing in http - // context. then for the same reason, we have to process these old items only when the cache - // is ready - // when moving, the moved node is also published, which is causing all sorts of troubles with - // descendants, so when moving, we lock events so that neither 'published' nor 'publishing' - // are processed more than once - // we cannot rely only on ContentCacheRefresher because when CacheUpdated triggers the old - // route is gone - // - // this is all very weird but it seems to work - ContentService.Publishing += ContentService_Publishing; ContentService.Published += ContentService_Published; ContentService.Moving += ContentService_Moving; ContentService.Moved += ContentService_Moved; - ContentCacheRefresher.CacheUpdated += ContentCacheRefresher_CacheUpdated; - // kill all redirects once a content is deleted //ContentService.Deleted += ContentService_Deleted; // BUT, doing it here would prevent content deletion due to FK @@ -119,102 +54,79 @@ namespace Umbraco.Web.Routing public void Terminate() { } - private static void ContentCacheRefresher_CacheUpdated(ContentCacheRefresher sender, CacheRefresherEventArgs args) + private void ContentService_Publishing(IContentService sender, PublishEventArgs args) { - // that event is a distributed even that triggers on all nodes - // BUT it should totally NOT run on nodes other that the one that handled the other events - // and besides, it cannot run on a background thread! - if (!HasOldRoutes) - return; - - // sanity checks - if (args.MessageType != MessageType.RefreshByPayload) - { - throw new InvalidOperationException("ContentCacheRefresher MessageType should be ByPayload."); - } - - if (args.MessageObject == null) - { - return; - } - - if (!(args.MessageObject is ContentCacheRefresher.JsonPayload[])) - { - throw new InvalidOperationException("ContentCacheRefresher MessageObject should be JsonPayload[]."); - } - - // manage routes - var removeKeys = new List(); - - foreach (var oldRoute in OldRoutes) - { - // assuming we cannot have 'CacheUpdated' for only part of the infos else we'd need - // to set a flag in 'Published' to indicate which entities have been refreshed ok - CreateRedirect(oldRoute.Key.ContentId, oldRoute.Key.Culture, oldRoute.Value.ContentKey, oldRoute.Value.OldRoute); - removeKeys.Add(oldRoute.Key); - } - - foreach (var k in removeKeys) - { - OldRoutes.Remove(k); - } - } - - private static void ContentService_Publishing(IContentService sender, PublishEventArgs args) - { - if (LockedEvents) return; - - var contentCache = Current.UmbracoContext.Content; + var oldRoutes = GetOldRoutes(args.EventState); foreach (var entity in args.PublishedEntities) { - var entityContent = contentCache.GetById(entity.Id); - if (entityContent == null) continue; + StoreOldRoute(entity, oldRoutes); + } + } - // get the default affected cultures by going up the tree until we find the first culture variant entity (default to no cultures) - var defaultCultures = entityContent.AncestorsOrSelf()?.FirstOrDefault(a => a.Cultures.Any())?.Cultures.Keys.ToArray() - ?? new[] {(string) null}; - foreach (var x in entityContent.DescendantsOrSelf()) - { - // if this entity defines specific cultures, use those instead of the default ones - var cultures = x.Cultures.Any() ? x.Cultures.Keys : defaultCultures; + private void ContentService_Published(IContentService sender, ContentPublishedEventArgs args) + { + var oldRoutes = GetOldRoutes(args.EventState); + CreateRedirects(oldRoutes); + } - foreach (var culture in cultures) - { - var route = contentCache.GetRouteById(x.Id, culture); - if (IsNotRoute(route)) return; - OldRoutes[new ContentIdAndCulture(x.Id, culture)] = new ContentKeyAndOldRoute(x.Key, route); - } - } + private void ContentService_Moving(IContentService sender, MoveEventArgs args) + { + var oldRoutes = GetOldRoutes(args.EventState); + foreach (var info in args.MoveInfoCollection) + { + StoreOldRoute(info.Entity, oldRoutes); + } + } + + private void ContentService_Moved(IContentService sender, MoveEventArgs args) + { + var oldRoutes = GetOldRoutes(args.EventState); + CreateRedirects(oldRoutes); + } + + private OldRoutesDictionary GetOldRoutes(IDictionary eventState) + { + if (! eventState.ContainsKey(_eventStateKey)) + { + eventState[_eventStateKey] = new OldRoutesDictionary(); } - LockedEvents = true; // we only want to see the "first batch" + return eventState[_eventStateKey] as OldRoutesDictionary; } - private static void ContentService_Published(IContentService sender, PublishEventArgs e) + private void StoreOldRoute(IContent entity, OldRoutesDictionary oldRoutes) { - // look note in CacheUpdated - // we might want to set a flag on the entities we are seeing here + var contentCache = _publishedSnapshotAccessor.PublishedSnapshot.Content; + var entityContent = contentCache.GetById(entity.Id); + if (entityContent == null) return; + + // get the default affected cultures by going up the tree until we find the first culture variant entity (default to no cultures) + var defaultCultures = entityContent.AncestorsOrSelf()?.FirstOrDefault(a => a.Cultures.Any())?.Cultures.Keys.ToArray() + ?? new[] { (string)null }; + foreach (var x in entityContent.DescendantsOrSelf()) + { + // if this entity defines specific cultures, use those instead of the default ones + var cultures = x.Cultures.Any() ? x.Cultures.Keys : defaultCultures; + + foreach (var culture in cultures) + { + var route = contentCache.GetRouteById(x.Id, culture); + if (IsNotRoute(route)) continue; + oldRoutes[new ContentIdAndCulture(x.Id, culture)] = new ContentKeyAndOldRoute(x.Key, route); + } + } } - private static void ContentService_Moving(IContentService sender, MoveEventArgs e) + private void CreateRedirects(OldRoutesDictionary oldRoutes) { - // TODO: Use the new e.EventState to track state between Moving/Moved events! - Moving = true; - } + var contentCache = _publishedSnapshotAccessor.PublishedSnapshot.Content; - private static void ContentService_Moved(IContentService sender, MoveEventArgs e) - { - Moving = false; - LockedEvents = false; - } - - private static void CreateRedirect(int contentId, string culture, Guid contentKey, string oldRoute) - { - var contentCache = Current.UmbracoContext.Content; - var newRoute = contentCache.GetRouteById(contentId, culture); - if (IsNotRoute(newRoute) || oldRoute == newRoute) return; - var redirectUrlService = Current.Services.RedirectUrlService; - redirectUrlService.Register(oldRoute, contentKey, culture); + foreach (var oldRoute in oldRoutes) + { + var newRoute = contentCache.GetRouteById(oldRoute.Key.ContentId, oldRoute.Key.Culture); + if (IsNotRoute(newRoute) || oldRoute.Value.OldRoute == newRoute) continue; + _redirectUrlService.Register(oldRoute.Value.OldRoute, oldRoute.Value.ContentKey, oldRoute.Key.Culture); + } } private static bool IsNotRoute(string route) @@ -243,5 +155,8 @@ namespace Umbraco.Web.Routing public Guid ContentKey => Item1; public string OldRoute => Item2; } + + private class OldRoutesDictionary : Dictionary + { } } } diff --git a/src/Umbraco.Web/Routing/UrlProvider.cs b/src/Umbraco.Web/Routing/UrlProvider.cs index 59e39fa80a..d42639b781 100644 --- a/src/Umbraco.Web/Routing/UrlProvider.cs +++ b/src/Umbraco.Web/Routing/UrlProvider.cs @@ -75,6 +75,7 @@ namespace Umbraco.Web.Routing private UrlMode GetMode(bool absolute) => absolute ? UrlMode.Absolute : Mode; private IPublishedContent GetDocument(int id) => _umbracoContext.Content.GetById(id); private IPublishedContent GetDocument(Guid id) => _umbracoContext.Content.GetById(id); + private IPublishedContent GetMedia(Guid id) => _umbracoContext.Media.GetById(id); /// /// Gets the url of a published content. @@ -184,6 +185,18 @@ namespace Umbraco.Web.Routing #region GetMediaUrl + /// + /// Gets the url of a media item. + /// + /// + /// + /// + /// + /// + /// + public string GetMediaUrl(Guid id, UrlMode mode = UrlMode.Default, string culture = null, string propertyAlias = Constants.Conventions.Media.File, Uri current = null) + => GetMediaUrl(GetMedia(id), mode, culture, propertyAlias, current); + /// /// Gets the url of a media item. /// diff --git a/src/Umbraco.Web/Runtime/WebFinalComponent.cs b/src/Umbraco.Web/Runtime/WebFinalComponent.cs index 6177d9b868..2ba78b2080 100644 --- a/src/Umbraco.Web/Runtime/WebFinalComponent.cs +++ b/src/Umbraco.Web/Runtime/WebFinalComponent.cs @@ -1,39 +1,18 @@ using System; -using System.Linq; -using System.Web.Helpers; using System.Web.Http; -using System.Web.Mvc; -using System.Web.Routing; -using Umbraco.Core; using Umbraco.Core.Composing; -using Umbraco.Core.Configuration; -using Umbraco.Web.Install; -using Umbraco.Web.Mvc; -using Umbraco.Web.Security; -using Umbraco.Web.WebApi; namespace Umbraco.Web.Runtime { public class WebFinalComponent : IComponent { - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly SurfaceControllerTypeCollection _surfaceControllerTypes; - private readonly UmbracoApiControllerTypeCollection _apiControllerTypes; - private readonly IGlobalSettings _globalSettings; - public WebFinalComponent(IUmbracoContextAccessor umbracoContextAccessor, SurfaceControllerTypeCollection surfaceControllerTypes, UmbracoApiControllerTypeCollection apiControllerTypes, IGlobalSettings globalSettings) + public WebFinalComponent() { - _umbracoContextAccessor = umbracoContextAccessor; - _surfaceControllerTypes = surfaceControllerTypes; - _apiControllerTypes = apiControllerTypes; - _globalSettings = globalSettings; } public void Initialize() { - // set routes - CreateRoutes(_umbracoContextAccessor, _globalSettings, _surfaceControllerTypes, _apiControllerTypes); - // ensure WebAPI is initialized, after everything GlobalConfiguration.Configuration.EnsureInitialized(); } @@ -41,92 +20,5 @@ namespace Umbraco.Web.Runtime public void Terminate() { } - // internal for tests - internal static void CreateRoutes( - IUmbracoContextAccessor umbracoContextAccessor, - IGlobalSettings globalSettings, - SurfaceControllerTypeCollection surfaceControllerTypes, - UmbracoApiControllerTypeCollection apiControllerTypes) - { - var umbracoPath = globalSettings.GetUmbracoMvcArea(); - - // create the front-end route - var defaultRoute = RouteTable.Routes.MapRoute( - "Umbraco_default", - umbracoPath + "/RenderMvc/{action}/{id}", - new { controller = "RenderMvc", action = "Index", id = UrlParameter.Optional } - ); - defaultRoute.RouteHandler = new RenderRouteHandler(umbracoContextAccessor, ControllerBuilder.Current.GetControllerFactory()); - - // register install routes - RouteTable.Routes.RegisterArea(); - - // register all back office routes - RouteTable.Routes.RegisterArea(new BackOfficeArea(globalSettings)); - - // plugin controllers must come first because the next route will catch many things - RoutePluginControllers(globalSettings, surfaceControllerTypes, apiControllerTypes); - } - - private static void RoutePluginControllers( - IGlobalSettings globalSettings, - SurfaceControllerTypeCollection surfaceControllerTypes, - UmbracoApiControllerTypeCollection apiControllerTypes) - { - var umbracoPath = globalSettings.GetUmbracoMvcArea(); - - // need to find the plugin controllers and route them - var pluginControllers = surfaceControllerTypes.Concat(apiControllerTypes).ToArray(); - - // local controllers do not contain the attribute - var localControllers = pluginControllers.Where(x => PluginController.GetMetadata(x).AreaName.IsNullOrWhiteSpace()); - foreach (var s in localControllers) - { - if (TypeHelper.IsTypeAssignableFrom(s)) - RouteLocalSurfaceController(s, umbracoPath); - else if (TypeHelper.IsTypeAssignableFrom(s)) - RouteLocalApiController(s, umbracoPath); - } - - // get the plugin controllers that are unique to each area (group by) - var pluginSurfaceControlleres = pluginControllers.Where(x => PluginController.GetMetadata(x).AreaName.IsNullOrWhiteSpace() == false); - var groupedAreas = pluginSurfaceControlleres.GroupBy(controller => PluginController.GetMetadata(controller).AreaName); - // loop through each area defined amongst the controllers - foreach (var g in groupedAreas) - { - // create & register an area for the controllers (this will throw an exception if all controllers are not in the same area) - var pluginControllerArea = new PluginControllerArea(globalSettings, g.Select(PluginController.GetMetadata)); - RouteTable.Routes.RegisterArea(pluginControllerArea); - } - } - - private static void RouteLocalApiController(Type controller, string umbracoPath) - { - var meta = PluginController.GetMetadata(controller); - var url = umbracoPath + (meta.IsBackOffice ? "/BackOffice" : "") + "/Api/" + meta.ControllerName + "/{action}/{id}"; - var route = RouteTable.Routes.MapHttpRoute( - $"umbraco-api-{meta.ControllerName}", - url, // url to match - new { controller = meta.ControllerName, id = UrlParameter.Optional }, - new[] { meta.ControllerNamespace }); - if (route.DataTokens == null) // web api routes don't set the data tokens object - route.DataTokens = new RouteValueDictionary(); - route.DataTokens.Add(Core.Constants.Web.UmbracoDataToken, "api"); //ensure the umbraco token is set - } - - private static void RouteLocalSurfaceController(Type controller, string umbracoPath) - { - var meta = PluginController.GetMetadata(controller); - var url = umbracoPath + "/Surface/" + meta.ControllerName + "/{action}/{id}"; - var route = RouteTable.Routes.MapRoute( - $"umbraco-surface-{meta.ControllerName}", - url, // url to match - new { controller = meta.ControllerName, action = "Index", id = UrlParameter.Optional }, - new[] { meta.ControllerNamespace }); // look in this namespace to create the controller - route.DataTokens.Add(Core.Constants.Web.UmbracoDataToken, "surface"); // ensure the umbraco token is set - route.DataTokens.Add("UseNamespaceFallback", false); // don't look anywhere else except this namespace! - // make it use our custom/special SurfaceMvcHandler - route.RouteHandler = new SurfaceRouteHandler(); - } } } diff --git a/src/Umbraco.Web/Runtime/WebInitialComponent.cs b/src/Umbraco.Web/Runtime/WebInitialComponent.cs index e1e0f4d80a..ac813d7196 100644 --- a/src/Umbraco.Web/Runtime/WebInitialComponent.cs +++ b/src/Umbraco.Web/Runtime/WebInitialComponent.cs @@ -8,12 +8,14 @@ using System.Web.Configuration; using System.Web.Http; using System.Web.Http.Dispatcher; using System.Web.Mvc; +using System.Web.Routing; using ClientDependency.Core.CompositeFiles.Providers; using ClientDependency.Core.Config; using Umbraco.Core; using Umbraco.Core.Composing; using Umbraco.Core.Configuration; using Umbraco.Core.IO; +using Umbraco.Web.Install; using Umbraco.Web.JavaScript; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; @@ -24,10 +26,16 @@ namespace Umbraco.Web.Runtime { public sealed class WebInitialComponent : IComponent { + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + private readonly SurfaceControllerTypeCollection _surfaceControllerTypes; + private readonly UmbracoApiControllerTypeCollection _apiControllerTypes; private readonly IGlobalSettings _globalSettings; - public WebInitialComponent(IGlobalSettings globalSettings) + public WebInitialComponent(IUmbracoContextAccessor umbracoContextAccessor, SurfaceControllerTypeCollection surfaceControllerTypes, UmbracoApiControllerTypeCollection apiControllerTypes, IGlobalSettings globalSettings) { + _umbracoContextAccessor = umbracoContextAccessor; + _surfaceControllerTypes = surfaceControllerTypes; + _apiControllerTypes = apiControllerTypes; _globalSettings = globalSettings; } @@ -51,6 +59,9 @@ namespace Umbraco.Web.Runtime // add global filters ConfigureGlobalFilters(); + + // set routes + CreateRoutes(_umbracoContextAccessor, _globalSettings, _surfaceControllerTypes, _apiControllerTypes); } public void Terminate() @@ -131,5 +142,94 @@ namespace Umbraco.Web.Runtime ClientDependencySettings.Instance.MvcRendererCollection.Add(renderer); } + + // internal for tests + internal static void CreateRoutes( + IUmbracoContextAccessor umbracoContextAccessor, + IGlobalSettings globalSettings, + SurfaceControllerTypeCollection surfaceControllerTypes, + UmbracoApiControllerTypeCollection apiControllerTypes) + { + var umbracoPath = globalSettings.GetUmbracoMvcArea(); + + // create the front-end route + var defaultRoute = RouteTable.Routes.MapRoute( + "Umbraco_default", + umbracoPath + "/RenderMvc/{action}/{id}", + new { controller = "RenderMvc", action = "Index", id = UrlParameter.Optional } + ); + defaultRoute.RouteHandler = new RenderRouteHandler(umbracoContextAccessor, ControllerBuilder.Current.GetControllerFactory()); + + // register install routes + RouteTable.Routes.RegisterArea(); + + // register all back office routes + RouteTable.Routes.RegisterArea(new BackOfficeArea(globalSettings)); + + // plugin controllers must come first because the next route will catch many things + RoutePluginControllers(globalSettings, surfaceControllerTypes, apiControllerTypes); + } + + private static void RoutePluginControllers( + IGlobalSettings globalSettings, + SurfaceControllerTypeCollection surfaceControllerTypes, + UmbracoApiControllerTypeCollection apiControllerTypes) + { + var umbracoPath = globalSettings.GetUmbracoMvcArea(); + + // need to find the plugin controllers and route them + var pluginControllers = surfaceControllerTypes.Concat(apiControllerTypes).ToArray(); + + // local controllers do not contain the attribute + var localControllers = pluginControllers.Where(x => PluginController.GetMetadata(x).AreaName.IsNullOrWhiteSpace()); + foreach (var s in localControllers) + { + if (TypeHelper.IsTypeAssignableFrom(s)) + RouteLocalSurfaceController(s, umbracoPath); + else if (TypeHelper.IsTypeAssignableFrom(s)) + RouteLocalApiController(s, umbracoPath); + } + + // get the plugin controllers that are unique to each area (group by) + var pluginSurfaceControlleres = pluginControllers.Where(x => PluginController.GetMetadata(x).AreaName.IsNullOrWhiteSpace() == false); + var groupedAreas = pluginSurfaceControlleres.GroupBy(controller => PluginController.GetMetadata(controller).AreaName); + // loop through each area defined amongst the controllers + foreach (var g in groupedAreas) + { + // create & register an area for the controllers (this will throw an exception if all controllers are not in the same area) + var pluginControllerArea = new PluginControllerArea(globalSettings, g.Select(PluginController.GetMetadata)); + RouteTable.Routes.RegisterArea(pluginControllerArea); + } + } + + private static void RouteLocalApiController(Type controller, string umbracoPath) + { + var meta = PluginController.GetMetadata(controller); + var url = umbracoPath + (meta.IsBackOffice ? "/BackOffice" : "") + "/Api/" + meta.ControllerName + "/{action}/{id}"; + var route = RouteTable.Routes.MapHttpRoute( + $"umbraco-api-{meta.ControllerName}", + url, // url to match + new { controller = meta.ControllerName, id = UrlParameter.Optional }, + new[] { meta.ControllerNamespace }); + if (route.DataTokens == null) // web api routes don't set the data tokens object + route.DataTokens = new RouteValueDictionary(); + route.DataTokens.Add(Core.Constants.Web.UmbracoDataToken, "api"); //ensure the umbraco token is set + } + + private static void RouteLocalSurfaceController(Type controller, string umbracoPath) + { + var meta = PluginController.GetMetadata(controller); + var url = umbracoPath + "/Surface/" + meta.ControllerName + "/{action}/{id}"; + var route = RouteTable.Routes.MapRoute( + $"umbraco-surface-{meta.ControllerName}", + url, // url to match + new { controller = meta.ControllerName, action = "Index", id = UrlParameter.Optional }, + new[] { meta.ControllerNamespace }); // look in this namespace to create the controller + route.DataTokens.Add(Core.Constants.Web.UmbracoDataToken, "surface"); // ensure the umbraco token is set + route.DataTokens.Add("UseNamespaceFallback", false); // don't look anywhere else except this namespace! + // make it use our custom/special SurfaceMvcHandler + route.RouteHandler = new SurfaceRouteHandler(); + } + } } diff --git a/src/Umbraco.Web/Runtime/WebInitialComposer.cs b/src/Umbraco.Web/Runtime/WebInitialComposer.cs index e2b6313ca6..1c4121da0c 100644 --- a/src/Umbraco.Web/Runtime/WebInitialComposer.cs +++ b/src/Umbraco.Web/Runtime/WebInitialComposer.cs @@ -9,9 +9,7 @@ using Umbraco.Core.Dashboards; using Umbraco.Core.Dictionary; using Umbraco.Core.Events; using Umbraco.Core.Migrations.PostMigrations; -using Umbraco.Web.Migrations.PostMigrations; using Umbraco.Core.Models.PublishedContent; -using Umbraco.Core.PropertyEditors; using Umbraco.Core.PropertyEditors.ValueConverters; using Umbraco.Core.Runtime; using Umbraco.Core.Services; @@ -19,7 +17,6 @@ using Umbraco.Web.Actions; using Umbraco.Web.Cache; using Umbraco.Web.Composing.CompositionExtensions; using Umbraco.Web.ContentApps; -using Umbraco.Web.Dashboards; using Umbraco.Web.Dictionary; using Umbraco.Web.Editors; using Umbraco.Web.Features; @@ -37,10 +34,10 @@ using Umbraco.Web.Security.Providers; using Umbraco.Web.Services; using Umbraco.Web.SignalR; using Umbraco.Web.Templates; -using Umbraco.Web.Tour; using Umbraco.Web.Trees; using Umbraco.Web.WebApi; using Current = Umbraco.Web.Composing.Current; +using Umbraco.Web.PropertyEditors; namespace Umbraco.Web.Runtime { @@ -95,7 +92,7 @@ namespace Umbraco.Web.Runtime // a way to inject the UmbracoContext - DO NOT register this as Lifetime.Request since LI will dispose the context // in it's own way but we don't want that to happen, we manage its lifetime ourselves. composition.Register(factory => factory.GetInstance().UmbracoContext); - + composition.RegisterUnique(); composition.Register(factory => { var umbCtx = factory.GetInstance(); @@ -107,6 +104,11 @@ namespace Umbraco.Web.Runtime composition.RegisterUnique(); composition.RegisterUnique(); + composition.RegisterUnique(); + composition.RegisterUnique(); + composition.RegisterUnique(); + composition.RegisterUnique(); + // register the umbraco helper - this is Transient! very important! // also, if not level.Run, we cannot really use the helper (during upgrade...) // so inject a "void" helper (not exactly pretty but...) @@ -125,6 +127,8 @@ namespace Umbraco.Web.Runtime // register distributed cache composition.RegisterUnique(f => new DistributedCache()); + composition.RegisterUnique(); + // replace some services composition.RegisterUnique(); composition.RegisterUnique(); @@ -142,19 +146,19 @@ namespace Umbraco.Web.Runtime .ComposeUmbracoControllers(GetType().Assembly) .SetDefaultRenderMvcController(); // default controller for template views - composition.WithCollectionBuilder() + composition.SearchableTrees() .Add(() => composition.TypeLoader.GetTypes()); composition.Register(Lifetime.Request); - composition.WithCollectionBuilder() + composition.EditorValidators() .Add(() => composition.TypeLoader.GetTypes()); - composition.WithCollectionBuilder(); + composition.TourFilters(); composition.RegisterUnique(); - composition.WithCollectionBuilder() + composition.Actions() .Add(() => composition.TypeLoader.GetTypes()); //we need to eagerly scan controller types since they will need to be routed @@ -169,26 +173,26 @@ namespace Umbraco.Web.Runtime // here because there cannot be two converters for one property editor - and we want the full // RteMacroRenderingValueConverter that converts macros, etc. So remove TinyMceValueConverter. // (the limited one, defined in Core, is there for tests) - same for others - composition.WithCollectionBuilder() + composition.PropertyValueConverters() .Remove() .Remove() .Remove(); - + // add all known factories, devs can then modify this list on application // startup either by binding to events or in their own global.asax - composition.WithCollectionBuilder() + composition.FilteredControllerFactory() .Append(); - composition.WithCollectionBuilder() + composition.UrlProviders() .Append() .Append(); - composition.WithCollectionBuilder() + composition.MediaUrlProviders() .Append(); composition.RegisterUnique(); - composition.WithCollectionBuilder() + composition.ContentFinders() // all built-in finders in the correct order, // devs can then modify this list on application startup .Append() @@ -203,7 +207,7 @@ namespace Umbraco.Web.Runtime composition.RegisterUnique(); // register *all* checks, except those marked [HideFromTypeFinder] of course - composition.WithCollectionBuilder() + composition.HealthChecks() .Add(() => composition.TypeLoader.GetTypes()); composition.WithCollectionBuilder() @@ -223,13 +227,13 @@ namespace Umbraco.Web.Runtime composition.RegisterUnique(); // register known content apps - composition.WithCollectionBuilder() + composition.ContentApps() .Append() .Append() .Append(); // register back office sections in the order we want them rendered - composition.WithCollectionBuilder() + composition.Sections() .Append() .Append() .Append() @@ -240,18 +244,18 @@ namespace Umbraco.Web.Runtime .Append(); // register core CMS dashboards and 3rd party types - will be ordered by weight attribute & merged with package.manifest dashboards - composition.WithCollectionBuilder() + composition.Dashboards() .Add(composition.TypeLoader.GetTypes()); // register back office trees // the collection builder only accepts types inheriting from TreeControllerBase // and will filter out those that are not attributed with TreeAttribute - composition.WithCollectionBuilder() + composition.Trees() .AddTreeControllers(umbracoApiControllerTypes.Where(x => typeof(TreeControllerBase).IsAssignableFrom(x))); // register OEmbed providers - no type scanning - all explicit opt-in of adding types // note: IEmbedProvider is not IDiscoverable - think about it if going for type scanning - composition.WithCollectionBuilder() + composition.OEmbedProviders() .Append() .Append() .Append() @@ -266,7 +270,7 @@ namespace Umbraco.Web.Runtime .Append() .Append() .Append(); - + // replace with web implementation composition.RegisterUnique(); diff --git a/src/Umbraco.Web/Runtime/WebRuntime.cs b/src/Umbraco.Web/Runtime/WebRuntime.cs index 13cd717fd1..ffcd2343ed 100644 --- a/src/Umbraco.Web/Runtime/WebRuntime.cs +++ b/src/Umbraco.Web/Runtime/WebRuntime.cs @@ -1,4 +1,6 @@ -using System.Web; +using System; +using System.Web; +using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Composing; using Umbraco.Core.Configuration; @@ -17,11 +19,18 @@ namespace Umbraco.Web.Runtime private readonly UmbracoApplicationBase _umbracoApplication; private IProfiler _webProfiler; + [Obsolete("Use the ctor with all parameters instead")] + public WebRuntime(UmbracoApplicationBase umbracoApplication) + : this(umbracoApplication, null, null) + { + } + /// /// Initializes a new instance of the class. /// /// - public WebRuntime(UmbracoApplicationBase umbracoApplication) + public WebRuntime(UmbracoApplicationBase umbracoApplication, ILogger logger, IMainDom mainDom) + : base(logger, mainDom) { _umbracoApplication = umbracoApplication; } diff --git a/src/Umbraco.Web/Scheduling/BackgroundTaskRunner.cs b/src/Umbraco.Web/Scheduling/BackgroundTaskRunner.cs index 47e97593f0..c0475b1f79 100644 --- a/src/Umbraco.Web/Scheduling/BackgroundTaskRunner.cs +++ b/src/Umbraco.Web/Scheduling/BackgroundTaskRunner.cs @@ -199,7 +199,7 @@ namespace Umbraco.Web.Scheduling { lock (_locker) { - var task = _runningTask ?? Task.FromResult(0); + var task = _runningTask ?? Task.CompletedTask; return new ThreadingTaskImmutable(task); } } @@ -211,8 +211,9 @@ namespace Umbraco.Web.Scheduling /// An awaitable object. /// /// Used to wait until the runner has terminated. - /// This is for unit tests and should not be used otherwise. In most cases when the runner - /// has terminated, the application domain is going down and it is not the right time to do things. + /// + /// The only time the runner will be terminated is by the Hosting Environment when the application is being shutdown. + /// /// internal ThreadingTaskImmutable TerminatedAwaitable { @@ -338,29 +339,37 @@ namespace Umbraco.Web.Scheduling if (_isRunning == false) return; // done already } + var hasTasks = TaskCount > 0; + + if (!force && hasTasks) + _logger.Info("{LogPrefix} Waiting for tasks to complete", _logPrefix); + // complete the queue // will stop waiting on the queue or on a latch _tasks.Complete(); if (force) { - // we must bring everything down, now - Thread.Sleep(100); // give time to Complete() + // we must bring everything down, now lock (_locker) { // was Complete() enough? - if (_isRunning == false) return; + // if _tasks.Complete() ended up triggering code to stop the runner and reset + // the _isRunning flag, then there's no need to initiate a cancel on the cancelation token. + if (_isRunning == false) + return; } + // try to cancel running async tasks (cannot do much about sync tasks) // break latched tasks // stop processing the queue - _shutdownTokenSource.Cancel(false); // false is the default - _shutdownTokenSource.Dispose(); + _shutdownTokenSource?.Cancel(false); // false is the default + _shutdownTokenSource?.Dispose(); _shutdownTokenSource = null; } // tasks in the queue will be executed... - if (wait == false) return; + if (!wait) return; _runningTask?.Wait(CancellationToken.None); // wait for whatever is running to end... } @@ -428,7 +437,7 @@ namespace Umbraco.Web.Scheduling lock (_locker) { // deal with race condition - if (_shutdownToken.IsCancellationRequested == false && _tasks.Count > 0) continue; + if (_shutdownToken.IsCancellationRequested == false && TaskCount > 0) continue; // if we really have nothing to do, stop _logger.Debug("{LogPrefix} Stopping", _logPrefix); @@ -453,7 +462,7 @@ namespace Umbraco.Web.Scheduling // if KeepAlive is false then don't block, exit if there is // no task in the buffer - yes, there is a race condition, which // we'll take care of - if (_options.KeepAlive == false && _tasks.Count == 0) + if (_options.KeepAlive == false && TaskCount == 0) return null; try @@ -503,15 +512,19 @@ namespace Umbraco.Web.Scheduling // returns the task that completed // - latched.Latch completes when the latch releases // - _tasks.Completion completes when the runner completes - // - tokenTaskSource.Task completes when this task, or the whole runner, is cancelled + // - tokenTaskSource.Task completes when this task, or the whole runner is cancelled var task = await Task.WhenAny(latched.Latch, _tasks.Completion, tokenTaskSource.Task); // ok to run now if (task == latched.Latch) return bgTask; + // we are shutting down if the _tasks.Complete(); was called or the shutdown token was cancelled + var isShuttingDown = _shutdownToken.IsCancellationRequested || task == _tasks.Completion; + // if shutting down, return the task only if it runs on shutdown - if (_shutdownToken.IsCancellationRequested == false && latched.RunsOnShutdown) return bgTask; + if (isShuttingDown && latched.RunsOnShutdown) + return bgTask; // else, either it does not run on shutdown or it's been cancelled, dispose latched.Dispose(); @@ -578,17 +591,18 @@ namespace Umbraco.Web.Scheduling // triggers when the hosting environment requests that the runner terminates internal event TypedEventHandler, EventArgs> Terminating; - // triggers when the runner has terminated (no task can be added, no task is running) + // triggers when the hosting environment has terminated (no task can be added, no task is running) internal event TypedEventHandler, EventArgs> Terminated; private void OnEvent(TypedEventHandler, EventArgs> handler, string name) { - if (handler == null) return; OnEvent(handler, name, EventArgs.Empty); } private void OnEvent(TypedEventHandler, TArgs> handler, string name, TArgs e) { + _logger.Debug("{LogPrefix} OnEvent {EventName}", _logPrefix, name); + if (handler == null) return; try @@ -664,17 +678,16 @@ namespace Umbraco.Web.Scheduling #endregion + #region IRegisteredObject.Stop + /// - /// Requests a registered object to un-register. + /// Used by IRegisteredObject.Stop and shutdown on threadpool threads to not block shutdown times. /// - /// true to indicate the registered object should un-register from the hosting - /// environment before returning; otherwise, false. - /// - /// "When the application manager needs to stop a registered object, it will call the Stop method." - /// The application manager will call the Stop method to ask a registered object to un-register. During - /// processing of the Stop method, the registered object must call the HostingEnvironment.UnregisterObject method. - /// - public void Stop(bool immediate) + /// + /// + /// An awaitable Task that is used to handle the shutdown. + /// + internal Task StopInternal(bool immediate) { // the first time the hosting environment requests that the runner terminates, // raise the Terminating event - that could be used to prevent any process that @@ -693,33 +706,90 @@ namespace Umbraco.Web.Scheduling if (onTerminating) OnEvent(Terminating, "Terminating"); - if (immediate == false) + // Run the Stop commands on another thread since IRegisteredObject.Stop calls are called sequentially + // with a single aspnet thread during shutdown and we don't want to delay other calls to IRegisteredObject.Stop. + if (!immediate) { - // The Stop method is first called with the immediate parameter set to false. The object can either complete - // processing, call the UnregisterObject method, and then return or it can return immediately and complete - // processing asynchronously before calling the UnregisterObject method. + return Task.Run(StopInitial, CancellationToken.None); + } + else + { + lock (_locker) + { + if (_terminated) return Task.CompletedTask; + return Task.Run(StopImmediate, CancellationToken.None); + } + } + } - _logger.Info("{LogPrefix} Waiting for tasks to complete", _logPrefix); + /// + /// Requests a registered object to un-register. + /// + /// true to indicate the registered object should un-register from the hosting + /// environment before returning; otherwise, false. + /// + /// "When the application manager needs to stop a registered object, it will call the Stop method." + /// The application manager will call the Stop method to ask a registered object to un-register. During + /// processing of the Stop method, the registered object must call the HostingEnvironment.UnregisterObject method. + /// + public void Stop(bool immediate) => StopInternal(immediate); + + /// + /// Called when immediate == false for IRegisteredObject.Stop(bool immediate) + /// + /// + /// Called on a threadpool thread + /// + private void StopInitial() + { + // immediate == false when the app is trying to wind down, immediate == true will be called either: + // after a call with immediate == false or if the app is not trying to wind down and needs to immediately stop. + // So Stop may be called twice or sometimes only once. + + try + { Shutdown(false, false); // do not accept any more tasks, flush the queue, do not wait - + } + finally + { // raise the completed event only after the running threading task has completed lock (_locker) { if (_runningTask != null) - _runningTask.ContinueWith(_ => Terminate(false)); + _runningTask.ContinueWith(_ => StopImmediate()); else - Terminate(false); + StopImmediate(); } } - else - { - // If the registered object does not complete processing before the application manager's time-out - // period expires, the Stop method is called again with the immediate parameter set to true. When the - // immediate parameter is true, the registered object must call the UnregisterObject method before returning; - // otherwise, its registration will be removed by the application manager. - _logger.Info("{LogPrefix} Canceling tasks", _logPrefix); + // If the shutdown token was not canceled in the Shutdown call above, it means there was still tasks + // being processed, in which case we'll give it a couple seconds + if (!_shutdownToken.IsCancellationRequested) + { + // If we are called with immediate == false, wind down above and then shutdown within 2 seconds, + // we want to shut down the app as quick as possible, if we wait until immediate == true, this can + // take a very long time since immediate will only be true when a new request is received on the new + // appdomain (or another iis timeout occurs ... which can take some time). + Thread.Sleep(2000); //we are already on a threadpool thread + StopImmediate(); + } + } + + /// + /// Called when immediate == true for IRegisteredObject.Stop(bool immediate) + /// + /// + /// Called on a threadpool thread + /// + private void StopImmediate() + { + _logger.Info("{LogPrefix} Canceling tasks", _logPrefix); + try + { Shutdown(true, true); // cancel all tasks, wait for the current one to end + } + finally + { Terminate(true); } } @@ -732,7 +802,13 @@ namespace Umbraco.Web.Scheduling // raise the Terminated event // complete the awaitable completion source, if any - HostingEnvironment.UnregisterObject(this); + if (immediate) + { + //only unregister when it's the final call, else we won't be notified of the final call + HostingEnvironment.UnregisterObject(this); + } + + if (_terminated) return; // already taken care of TaskCompletionSource terminatedSource; lock (_locker) @@ -747,7 +823,9 @@ namespace Umbraco.Web.Scheduling OnEvent(Terminated, "Terminated"); - terminatedSource.SetResult(0); + terminatedSource.TrySetResult(0); } + + #endregion } } diff --git a/src/Umbraco.Web/Scheduling/HealthCheckNotifier.cs b/src/Umbraco.Web/Scheduling/HealthCheckNotifier.cs index 4f66af11bd..746bc61e34 100644 --- a/src/Umbraco.Web/Scheduling/HealthCheckNotifier.cs +++ b/src/Umbraco.Web/Scheduling/HealthCheckNotifier.cs @@ -51,7 +51,7 @@ namespace Umbraco.Web.Scheduling return false; // do NOT repeat, going down } - using (_logger.DebugDuration("Health checks executing", "Health checks complete")) + using (_logger.DebugDuration("Health checks executing", "Health checks complete")) { var healthCheckConfig = Current.Configs.HealthChecks(); diff --git a/src/Umbraco.Web/Scheduling/KeepAlive.cs b/src/Umbraco.Web/Scheduling/KeepAlive.cs index c07430df04..9a22c59566 100644 --- a/src/Umbraco.Web/Scheduling/KeepAlive.cs +++ b/src/Umbraco.Web/Scheduling/KeepAlive.cs @@ -3,6 +3,8 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Umbraco.Core; +using Umbraco.Core.Composing; +using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Logging; using Umbraco.Core.Sync; @@ -11,14 +13,16 @@ namespace Umbraco.Web.Scheduling internal class KeepAlive : RecurringTaskBase { private readonly IRuntimeState _runtime; + private readonly IKeepAliveSection _keepAliveSection; private readonly IProfilingLogger _logger; private static HttpClient _httpClient; public KeepAlive(IBackgroundTaskRunner runner, int delayMilliseconds, int periodMilliseconds, - IRuntimeState runtime, IProfilingLogger logger) + IRuntimeState runtime, IKeepAliveSection keepAliveSection, IProfilingLogger logger) : base(runner, delayMilliseconds, periodMilliseconds) { _runtime = runtime; + _keepAliveSection = keepAliveSection; _logger = logger; if (_httpClient == null) _httpClient = new HttpClient(); @@ -46,25 +50,27 @@ namespace Umbraco.Web.Scheduling using (_logger.DebugDuration("Keep alive executing", "Keep alive complete")) { - string umbracoAppUrl = null; - + var keepAlivePingUrl = _keepAliveSection.KeepAlivePingUrl; try { - umbracoAppUrl = _runtime.ApplicationUrl.ToString(); - if (umbracoAppUrl.IsNullOrWhiteSpace()) + if (keepAlivePingUrl.Contains("{umbracoApplicationUrl}")) { - _logger.Warn("No url for service (yet), skip."); - return true; // repeat + var umbracoAppUrl = _runtime.ApplicationUrl.ToString(); + if (umbracoAppUrl.IsNullOrWhiteSpace()) + { + _logger.Warn("No umbracoApplicationUrl for service (yet), skip."); + return true; // repeat + } + + keepAlivePingUrl = keepAlivePingUrl.Replace("{umbracoApplicationUrl}", umbracoAppUrl.TrimEnd('/')); } - var url = umbracoAppUrl.TrimEnd('/') + "/api/keepalive/ping"; - - var request = new HttpRequestMessage(HttpMethod.Get, url); + var request = new HttpRequestMessage(HttpMethod.Get, keepAlivePingUrl); var result = await _httpClient.SendAsync(request, token); } catch (Exception ex) { - _logger.Error(ex, "Keep alive failed (at '{UmbracoAppUrl}').", umbracoAppUrl); + _logger.Error(ex, "Keep alive failed (at '{keepAlivePingUrl}').", keepAlivePingUrl); } } diff --git a/src/Umbraco.Web/Scheduling/LogScrubber.cs b/src/Umbraco.Web/Scheduling/LogScrubber.cs index c39f9df1b3..db13a80f9b 100644 --- a/src/Umbraco.Web/Scheduling/LogScrubber.cs +++ b/src/Umbraco.Web/Scheduling/LogScrubber.cs @@ -1,16 +1,14 @@ using System; -using System.Threading; -using System.Threading.Tasks; using Umbraco.Core; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.Logging; -using Umbraco.Core.Persistence; using Umbraco.Core.Scoping; using Umbraco.Core.Services; using Umbraco.Core.Sync; namespace Umbraco.Web.Scheduling { + internal class LogScrubber : RecurringTaskBase { private readonly IRuntimeState _runtime; diff --git a/src/Umbraco.Web/Scheduling/SchedulerComponent.cs b/src/Umbraco.Web/Scheduling/SchedulerComponent.cs index 5e29b17f56..a08289186f 100644 --- a/src/Umbraco.Web/Scheduling/SchedulerComponent.cs +++ b/src/Umbraco.Web/Scheduling/SchedulerComponent.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading; using Umbraco.Core; using Umbraco.Core.Composing; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.HealthChecks; using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Core.Scoping; using Umbraco.Core.Services; @@ -16,6 +18,11 @@ namespace Umbraco.Web.Scheduling { internal sealed class SchedulerComponent : IComponent { + private const int DefaultDelayMilliseconds = 180000; // 3 mins + private const int OneMinuteMilliseconds = 60000; + private const int FiveMinuteMilliseconds = 300000; + private const int OneHourMilliseconds = 3600000; + private readonly IRuntimeState _runtime; private readonly IContentService _contentService; private readonly IAuditService _auditService; @@ -29,6 +36,7 @@ namespace Umbraco.Web.Scheduling private BackgroundTaskRunner _publishingRunner; private BackgroundTaskRunner _tasksRunner; private BackgroundTaskRunner _scrubberRunner; + private BackgroundTaskRunner _fileCleanupRunner; private BackgroundTaskRunner _healthCheckRunner; private bool _started; @@ -58,6 +66,7 @@ namespace Umbraco.Web.Scheduling _publishingRunner = new BackgroundTaskRunner("ScheduledPublishing", _logger); _tasksRunner = new BackgroundTaskRunner("ScheduledTasks", _logger); _scrubberRunner = new BackgroundTaskRunner("LogScrubber", _logger); + _fileCleanupRunner = new BackgroundTaskRunner("TempFileCleanup", _logger); _healthCheckRunner = new BackgroundTaskRunner("HealthCheckNotifier", _logger); // we will start the whole process when a successful request is made @@ -90,9 +99,14 @@ namespace Umbraco.Web.Scheduling var tasks = new List(); - tasks.Add(RegisterKeepAlive()); + if (settings.KeepAlive.DisableKeepAliveTask == false) + { + tasks.Add(RegisterKeepAlive(settings.KeepAlive)); + } + tasks.Add(RegisterScheduledPublishing()); tasks.Add(RegisterLogScrubber(settings)); + tasks.Add(RegisterTempFileCleanup()); var healthCheckConfig = Current.Configs.HealthChecks(); if (healthCheckConfig.NotificationSettings.Enabled) @@ -102,11 +116,11 @@ namespace Umbraco.Web.Scheduling }); } - private IBackgroundTask RegisterKeepAlive() + private IBackgroundTask RegisterKeepAlive(IKeepAliveSection keepAliveSection) { // ping/keepalive // on all servers - var task = new KeepAlive(_keepAliveRunner, 60000, 300000, _runtime, _logger); + var task = new KeepAlive(_keepAliveRunner, DefaultDelayMilliseconds, FiveMinuteMilliseconds, _runtime, keepAliveSection, _logger); _keepAliveRunner.TryAdd(task); return task; } @@ -115,7 +129,7 @@ namespace Umbraco.Web.Scheduling { // scheduled publishing/unpublishing // install on all, will only run on non-replica servers - var task = new ScheduledPublishing(_publishingRunner, 60000, 60000, _runtime, _contentService, _umbracoContextFactory, _logger); + var task = new ScheduledPublishing(_publishingRunner, DefaultDelayMilliseconds, OneMinuteMilliseconds, _runtime, _contentService, _umbracoContextFactory, _logger); _publishingRunner.TryAdd(task); return task; } @@ -128,15 +142,15 @@ namespace Umbraco.Web.Scheduling int delayInMilliseconds; if (string.IsNullOrEmpty(healthCheckConfig.NotificationSettings.FirstRunTime)) { - delayInMilliseconds = 60000; + delayInMilliseconds = DefaultDelayMilliseconds; } else { // Otherwise start at scheduled time delayInMilliseconds = DateTime.Now.PeriodicMinutesFrom(healthCheckConfig.NotificationSettings.FirstRunTime) * 60 * 1000; - if (delayInMilliseconds < 60000) + if (delayInMilliseconds < DefaultDelayMilliseconds) { - delayInMilliseconds = 60000; + delayInMilliseconds = DefaultDelayMilliseconds; } } @@ -150,7 +164,19 @@ namespace Umbraco.Web.Scheduling { // log scrubbing // install on all, will only run on non-replica servers - var task = new LogScrubber(_scrubberRunner, 60000, LogScrubber.GetLogScrubbingInterval(settings, _logger), _runtime, _auditService, settings, _scopeProvider, _logger); + var task = new LogScrubber(_scrubberRunner, DefaultDelayMilliseconds, LogScrubber.GetLogScrubbingInterval(settings, _logger), _runtime, _auditService, settings, _scopeProvider, _logger); + _scrubberRunner.TryAdd(task); + return task; + } + + private IBackgroundTask RegisterTempFileCleanup() + { + // temp file cleanup, will run on all servers - even though file upload should only be handled on the master, this will + // ensure that in the case it happes on replicas that they are cleaned up. + var task = new TempFileCleanup(_fileCleanupRunner, DefaultDelayMilliseconds, OneHourMilliseconds, + new[] { new DirectoryInfo(IOHelper.MapPath(SystemDirectories.TempFileUploads)) }, + TimeSpan.FromDays(1), //files that are over a day old + _runtime, _logger); _scrubberRunner.TryAdd(task); return task; } diff --git a/src/Umbraco.Web/Scheduling/TempFileCleanup.cs b/src/Umbraco.Web/Scheduling/TempFileCleanup.cs new file mode 100644 index 0000000000..5d28bbfdb1 --- /dev/null +++ b/src/Umbraco.Web/Scheduling/TempFileCleanup.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Umbraco.Core; +using Umbraco.Core.Logging; + +namespace Umbraco.Web.Scheduling +{ + /// + /// Used to cleanup temporary file locations + /// + internal class TempFileCleanup : RecurringTaskBase + { + private readonly DirectoryInfo[] _tempFolders; + private readonly TimeSpan _age; + private readonly IRuntimeState _runtime; + private readonly IProfilingLogger _logger; + + public TempFileCleanup(IBackgroundTaskRunner runner, int delayMilliseconds, int periodMilliseconds, + IEnumerable tempFolders, TimeSpan age, + IRuntimeState runtime, IProfilingLogger logger) + : base(runner, delayMilliseconds, periodMilliseconds) + { + //SystemDirectories.TempFileUploads + + _tempFolders = tempFolders.ToArray(); + _age = age; + _runtime = runtime; + _logger = logger; + } + + public override bool PerformRun() + { + // ensure we do not run if not main domain + if (_runtime.IsMainDom == false) + { + _logger.Debug("Does not run if not MainDom."); + return false; // do NOT repeat, going down + } + + foreach (var dir in _tempFolders) + CleanupFolder(dir); + + return true; //repeat + } + + private void CleanupFolder(DirectoryInfo dir) + { + dir.Refresh(); //in case it's changed during runtime + if (!dir.Exists) + { + _logger.Debug("The cleanup folder doesn't exist {Folder}", dir.FullName); + } + + var files = dir.GetFiles("*.*", SearchOption.AllDirectories); + foreach (var file in files) + { + if (DateTime.UtcNow - file.LastWriteTimeUtc > _age) + { + try + { + file.Delete(); + } + catch (Exception ex) + { + _logger.Error(ex, "Could not delete temp file {FileName}", file.FullName); + } + } + } + } + + public override bool IsAsync => false; + } +} diff --git a/src/Umbraco.Web/Search/BackgroundIndexRebuilder.cs b/src/Umbraco.Web/Search/BackgroundIndexRebuilder.cs new file mode 100644 index 0000000000..0ae5ceade9 --- /dev/null +++ b/src/Umbraco.Web/Search/BackgroundIndexRebuilder.cs @@ -0,0 +1,123 @@ +using System; +using System.Threading; +using Umbraco.Core.Logging; +using Umbraco.Examine; +using System.Threading.Tasks; +using Umbraco.Core; +using Umbraco.Web.Scheduling; + +namespace Umbraco.Web.Search +{ + /// + /// Utility to rebuild all indexes on a background thread + /// + public sealed class BackgroundIndexRebuilder + { + private static readonly object RebuildLocker = new object(); + private readonly IndexRebuilder _indexRebuilder; + private readonly IMainDom _mainDom; + private readonly IProfilingLogger _logger; + private static BackgroundTaskRunner _rebuildOnStartupRunner; + + public BackgroundIndexRebuilder(IMainDom mainDom, IProfilingLogger logger, IndexRebuilder indexRebuilder) + { + _mainDom = mainDom; + _logger = logger; + _indexRebuilder = indexRebuilder; + } + + /// + /// Called to rebuild empty indexes on startup + /// + /// + /// + /// + /// + public void RebuildIndexes(bool onlyEmptyIndexes, int waitMilliseconds = 0) + { + // TODO: need a way to disable rebuilding on startup + + lock (RebuildLocker) + { + if (_rebuildOnStartupRunner != null && _rebuildOnStartupRunner.IsRunning) + { + _logger.Warn("Call was made to RebuildIndexes but the task runner for rebuilding is already running"); + return; + } + + _logger.Info("Starting initialize async background thread."); + //do the rebuild on a managed background thread + var task = new RebuildOnStartupTask(_mainDom, _indexRebuilder, _logger, onlyEmptyIndexes, waitMilliseconds); + + _rebuildOnStartupRunner = new BackgroundTaskRunner( + "RebuildIndexesOnStartup", + _logger); + + _rebuildOnStartupRunner.TryAdd(task); + } + } + + /// + /// Background task used to rebuild empty indexes on startup + /// + private class RebuildOnStartupTask : IBackgroundTask + { + private readonly IMainDom _mainDom; + + private readonly IndexRebuilder _indexRebuilder; + private readonly ILogger _logger; + private readonly bool _onlyEmptyIndexes; + private readonly int _waitMilliseconds; + + public RebuildOnStartupTask(IMainDom mainDom, + IndexRebuilder indexRebuilder, ILogger logger, bool onlyEmptyIndexes, int waitMilliseconds = 0) + { + _mainDom = mainDom; + _indexRebuilder = indexRebuilder ?? throw new ArgumentNullException(nameof(indexRebuilder)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _onlyEmptyIndexes = onlyEmptyIndexes; + _waitMilliseconds = waitMilliseconds; + } + + public bool IsAsync => false; + + public void Dispose() + { + } + + public void Run() + { + try + { + // rebuilds indexes + RebuildIndexes(); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to rebuild empty indexes."); + } + } + + public Task RunAsync(CancellationToken token) + { + throw new NotImplementedException(); + } + + /// + /// Used to rebuild indexes on startup or cold boot + /// + private void RebuildIndexes() + { + //do not attempt to do this if this has been disabled since we are not the main dom. + //this can be called during a cold boot + if (!_mainDom.IsMainDom) return; + + if (_waitMilliseconds > 0) + Thread.Sleep(_waitMilliseconds); + + _indexRebuilder.ExamineManager.ConfigureIndexes(_mainDom, _logger); + _indexRebuilder.RebuildIndexes(_onlyEmptyIndexes); + } + } + } +} diff --git a/src/Umbraco.Web/Search/ExamineComponent.cs b/src/Umbraco.Web/Search/ExamineComponent.cs index ed248a9e24..149b4d1436 100644 --- a/src/Umbraco.Web/Search/ExamineComponent.cs +++ b/src/Umbraco.Web/Search/ExamineComponent.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Threading; using Examine; using Umbraco.Core; using Umbraco.Core.Cache; @@ -15,46 +14,42 @@ using Umbraco.Core.Sync; using Umbraco.Web.Cache; using Umbraco.Examine; using Umbraco.Core.Persistence.DatabaseModelDefinitions; -using Umbraco.Web.Scheduling; -using System.Threading.Tasks; using Examine.LuceneEngine.Directories; using Umbraco.Core.Composing; +using System.ComponentModel; namespace Umbraco.Web.Search { - public sealed class ExamineComponent : IComponent + public sealed class ExamineComponent : Umbraco.Core.Composing.IComponent { private readonly IExamineManager _examineManager; private readonly IContentValueSetBuilder _contentValueSetBuilder; private readonly IPublishedContentValueSetBuilder _publishedContentValueSetBuilder; private readonly IValueSetBuilder _mediaValueSetBuilder; private readonly IValueSetBuilder _memberValueSetBuilder; - private static bool _disableExamineIndexing = false; - private static bool _isConfigured = false; - private static object _configuredInit = null; + private readonly BackgroundIndexRebuilder _backgroundIndexRebuilder; private static object _isConfiguredLocker = new object(); private readonly IScopeProvider _scopeProvider; - private readonly ServiceContext _services; - private static BackgroundTaskRunner _rebuildOnStartupRunner; - private static readonly object RebuildLocker = new object(); + private readonly ServiceContext _services; private readonly IMainDom _mainDom; private readonly IProfilingLogger _logger; private readonly IUmbracoIndexesCreator _indexCreator; - private readonly IndexRebuilder _indexRebuilder; + // the default enlist priority is 100 // enlist with a lower priority to ensure that anything "default" runs after us // but greater that SafeXmlReaderWriter priority which is 60 private const int EnlistPriority = 80; - + public ExamineComponent(IMainDom mainDom, IExamineManager examineManager, IProfilingLogger profilingLogger, IScopeProvider scopeProvider, IUmbracoIndexesCreator indexCreator, - IndexRebuilder indexRebuilder, ServiceContext services, + ServiceContext services, IContentValueSetBuilder contentValueSetBuilder, IPublishedContentValueSetBuilder publishedContentValueSetBuilder, IValueSetBuilder mediaValueSetBuilder, - IValueSetBuilder memberValueSetBuilder) + IValueSetBuilder memberValueSetBuilder, + BackgroundIndexRebuilder backgroundIndexRebuilder) { _services = services; _scopeProvider = scopeProvider; @@ -63,11 +58,10 @@ namespace Umbraco.Web.Search _publishedContentValueSetBuilder = publishedContentValueSetBuilder; _mediaValueSetBuilder = mediaValueSetBuilder; _memberValueSetBuilder = memberValueSetBuilder; - + _backgroundIndexRebuilder = backgroundIndexRebuilder; _mainDom = mainDom; _logger = profilingLogger; _indexCreator = indexCreator; - _indexRebuilder = indexRebuilder; } public void Initialize() @@ -96,7 +90,6 @@ namespace Umbraco.Web.Search //if we could not register the shutdown examine ourselves, it means we are not maindom! in this case all of examine should be disabled! Suspendable.ExamineEvents.SuspendIndexers(_logger); - _disableExamineIndexing = true; return; //exit, do not continue } @@ -117,70 +110,18 @@ namespace Umbraco.Web.Search // bind to distributed cache events - this ensures that this logic occurs on ALL servers // that are taking part in a load balanced environment. ContentCacheRefresher.CacheUpdated += ContentCacheRefresherUpdated; - ContentTypeCacheRefresher.CacheUpdated += ContentTypeCacheRefresherUpdated; ; + ContentTypeCacheRefresher.CacheUpdated += ContentTypeCacheRefresherUpdated; MediaCacheRefresher.CacheUpdated += MediaCacheRefresherUpdated; MemberCacheRefresher.CacheUpdated += MemberCacheRefresherUpdated; - - ConfigureIndexes(_logger, _examineManager); - - // TODO: Instead of waiting 5000 ms, we could add an event handler on to fulfilling the first request, then start? - RebuildIndexes(_indexRebuilder, _logger, true, 5000); + LanguageCacheRefresher.CacheUpdated += LanguageCacheRefresherUpdated; } public void Terminate() { } - /// - /// Called to rebuild empty indexes on startup - /// - /// - /// - /// - /// - public static void RebuildIndexes(IndexRebuilder indexRebuilder, ILogger logger, bool onlyEmptyIndexes, int waitMilliseconds = 0) - { - // TODO: need a way to disable rebuilding on startup - - lock(RebuildLocker) - { - if (_rebuildOnStartupRunner != null && _rebuildOnStartupRunner.IsRunning) - { - logger.Warn("Call was made to RebuildIndexes but the task runner for rebuilding is already running"); - return; - } - - logger.Info("Starting initialize async background thread."); - //do the rebuild on a managed background thread - var task = new RebuildOnStartupTask(indexRebuilder, logger, onlyEmptyIndexes, waitMilliseconds); - - _rebuildOnStartupRunner = new BackgroundTaskRunner( - "RebuildIndexesOnStartup", - logger); - - _rebuildOnStartupRunner.TryAdd(task); - } - } - - /// - /// Called on startup to configure each index. - /// - /// - /// Indexing rebuilding can occur on a normal boot if the indexes are empty or on a cold boot by the database server messenger. Before - /// either of these happens, we need to configure the indexes. - /// Configuring also ensure the indexes are not locked. - /// - private static void ConfigureIndexes(ILogger logger, IExamineManager examineManager) - { - LazyInitializer.EnsureInitialized( - ref _configuredInit, - ref _isConfigured, - ref _isConfiguredLocker, - () => - { - examineManager.ConfigureLuceneIndexes(logger, _disableExamineIndexing); - return null; - }); - } + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("This method should not be used and will be removed in future versions, rebuilding indexes can be done with the IndexRebuilder or the BackgroundIndexRebuilder")] + public static void RebuildIndexes(IndexRebuilder indexRebuilder, ILogger logger, bool onlyEmptyIndexes, int waitMilliseconds = 0) => Current.Factory.GetInstance().RebuildIndexes(onlyEmptyIndexes, waitMilliseconds); #region Cache refresher updated event handlers @@ -382,6 +323,25 @@ namespace Umbraco.Web.Search } } + private void LanguageCacheRefresherUpdated(LanguageCacheRefresher sender, CacheRefresherEventArgs e) + { + if (!(e.MessageObject is LanguageCacheRefresher.JsonPayload[] payloads)) + return; + + if (payloads.Length == 0) return; + + var removedOrCultureChanged = payloads.Any(x => + x.ChangeType == LanguageCacheRefresher.JsonPayload.LanguageChangeType.ChangeCulture + || x.ChangeType == LanguageCacheRefresher.JsonPayload.LanguageChangeType.Remove); + + if (removedOrCultureChanged) + { + //if a lang is removed or it's culture has changed, we need to rebuild the indexes since + //field names and values in the index have a string culture value. + _backgroundIndexRebuilder.RebuildIndexes(false); + } + } + /// /// Updates indexes based on content type changes /// @@ -746,63 +706,6 @@ namespace Umbraco.Web.Search } #endregion - /// - /// Background task used to rebuild empty indexes on startup - /// - private class RebuildOnStartupTask : IBackgroundTask - { - private readonly IndexRebuilder _indexRebuilder; - private readonly ILogger _logger; - private readonly bool _onlyEmptyIndexes; - private readonly int _waitMilliseconds; - - public RebuildOnStartupTask(IndexRebuilder indexRebuilder, ILogger logger, bool onlyEmptyIndexes, int waitMilliseconds = 0) - { - _indexRebuilder = indexRebuilder ?? throw new ArgumentNullException(nameof(indexRebuilder)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _onlyEmptyIndexes = onlyEmptyIndexes; - _waitMilliseconds = waitMilliseconds; - } - - public bool IsAsync => false; - - public void Dispose() - { - } - - public void Run() - { - try - { - // rebuilds indexes - RebuildIndexes(); - } - catch (Exception ex) - { - _logger.Error(ex, "Failed to rebuild empty indexes."); - } - } - - public Task RunAsync(CancellationToken token) - { - throw new NotImplementedException(); - } - - /// - /// Used to rebuild indexes on startup or cold boot - /// - private void RebuildIndexes() - { - //do not attempt to do this if this has been disabled since we are not the main dom. - //this can be called during a cold boot - if (_disableExamineIndexing) return; - - if (_waitMilliseconds > 0) - Thread.Sleep(_waitMilliseconds); - - ConfigureIndexes(_logger, _indexRebuilder.ExamineManager); - _indexRebuilder.RebuildIndexes(_onlyEmptyIndexes); - } - } + } } diff --git a/src/Umbraco.Web/Search/ExamineComposer.cs b/src/Umbraco.Web/Search/ExamineComposer.cs index 6e74f0e89d..b30f0cbe03 100644 --- a/src/Umbraco.Web/Search/ExamineComposer.cs +++ b/src/Umbraco.Web/Search/ExamineComposer.cs @@ -10,6 +10,7 @@ using Umbraco.Examine; namespace Umbraco.Web.Search { + /// /// Configures and installs Examine. /// @@ -28,6 +29,7 @@ namespace Umbraco.Web.Search composition.Register(Lifetime.Singleton); composition.Register(Lifetime.Singleton); + composition.RegisterUnique(); composition.RegisterUnique(); composition.RegisterUnique(factory => new ContentValueSetBuilder( @@ -43,6 +45,7 @@ namespace Umbraco.Web.Search false)); composition.RegisterUnique, MediaValueSetBuilder>(); composition.RegisterUnique, MemberValueSetBuilder>(); + composition.RegisterUnique(); //We want to manage Examine's AppDomain shutdown sequence ourselves so first we'll disable Examine's default behavior //and then we'll use MainDom to control Examine's shutdown - this MUST be done in Compose ie before ExamineManager diff --git a/src/Umbraco.Web/Search/ExamineFinalComponent.cs b/src/Umbraco.Web/Search/ExamineFinalComponent.cs new file mode 100644 index 0000000000..95000b2b46 --- /dev/null +++ b/src/Umbraco.Web/Search/ExamineFinalComponent.cs @@ -0,0 +1,42 @@ +using Examine; +using Umbraco.Core.Logging; +using Umbraco.Examine; +using Umbraco.Core.Composing; +using Umbraco.Core; + +namespace Umbraco.Web.Search +{ + + /// + /// Executes after all other examine components have executed + /// + public sealed class ExamineFinalComponent : IComponent + { + private readonly IProfilingLogger _logger; + private readonly IExamineManager _examineManager; + BackgroundIndexRebuilder _indexRebuilder; + private readonly IMainDom _mainDom; + + public ExamineFinalComponent(IProfilingLogger logger, IExamineManager examineManager, BackgroundIndexRebuilder indexRebuilder, IMainDom mainDom) + { + _logger = logger; + _examineManager = examineManager; + _indexRebuilder = indexRebuilder; + _mainDom = mainDom; + } + + public void Initialize() + { + if (!_mainDom.IsMainDom) return; + + _examineManager.ConfigureIndexes(_mainDom, _logger); + + // TODO: Instead of waiting 5000 ms, we could add an event handler on to fulfilling the first request, then start? + _indexRebuilder.RebuildIndexes(true, 5000); + } + + public void Terminate() + { + } + } +} diff --git a/src/Umbraco.Web/Search/ExamineFinalComposer.cs b/src/Umbraco.Web/Search/ExamineFinalComposer.cs new file mode 100644 index 0000000000..5b6334f1f6 --- /dev/null +++ b/src/Umbraco.Web/Search/ExamineFinalComposer.cs @@ -0,0 +1,13 @@ +using Umbraco.Core; +using Umbraco.Core.Composing; + +namespace Umbraco.Web.Search +{ + // examine's final composer composes after all user composers + // and *also* after ICoreComposer (in case IUserComposer is disabled) + [ComposeAfter(typeof(IUserComposer))] + [ComposeAfter(typeof(ICoreComposer))] + [RuntimeLevel(MinLevel = RuntimeLevel.Run)] + public class ExamineFinalComposer : ComponentComposer + { } +} diff --git a/src/Umbraco.Web/Search/ExamineUserComponent.cs b/src/Umbraco.Web/Search/ExamineUserComponent.cs new file mode 100644 index 0000000000..35bc3f59ea --- /dev/null +++ b/src/Umbraco.Web/Search/ExamineUserComponent.cs @@ -0,0 +1,37 @@ +using Umbraco.Core; +using Umbraco.Core.Composing; + +namespace Umbraco.Web.Search +{ + /// + /// An abstract class for custom index authors to inherit from + /// + public abstract class ExamineUserComponent : IComponent + { + private readonly IMainDom _mainDom; + + public ExamineUserComponent(IMainDom mainDom) + { + _mainDom = mainDom; + } + + /// + /// Initialize the component, eagerly exits if ExamineComponent.ExamineEnabled == false + /// + public void Initialize() + { + if (!_mainDom.IsMainDom) return; + + InitializeComponent(); + } + + /// + /// Abstract method which executes to initialize this component if ExamineComponent.ExamineEnabled == true + /// + protected abstract void InitializeComponent(); + + public virtual void Terminate() + { + } + } +} diff --git a/src/Umbraco.Web/Search/GenericIndexDiagnostics.cs b/src/Umbraco.Web/Search/GenericIndexDiagnostics.cs index 560fb19f09..cb25e1242a 100644 --- a/src/Umbraco.Web/Search/GenericIndexDiagnostics.cs +++ b/src/Umbraco.Web/Search/GenericIndexDiagnostics.cs @@ -8,6 +8,7 @@ using Umbraco.Examine; namespace Umbraco.Web.Search { + /// /// Used to return diagnostic data for any index /// diff --git a/src/Umbraco.Web/Search/IUmbracoTreeSearcherFields.cs b/src/Umbraco.Web/Search/IUmbracoTreeSearcherFields.cs new file mode 100644 index 0000000000..c5a6c53d19 --- /dev/null +++ b/src/Umbraco.Web/Search/IUmbracoTreeSearcherFields.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace Umbraco.Web.Search +{ + /// + /// Used to propagate hardcoded internal Field lists + /// + public interface IUmbracoTreeSearcherFields + { + /// + /// Propagate list of searchable fields for all node types + /// + IEnumerable GetBackOfficeFields(); + /// + /// Propagate list of searchable fields for Members + /// + IEnumerable GetBackOfficeMembersFields(); + /// + /// Propagate list of searchable fields for Media + /// + IEnumerable GetBackOfficeMediaFields(); + /// + /// Propagate list of searchable fields for Documents + /// + IEnumerable GetBackOfficeDocumentFields(); + } +} diff --git a/src/Umbraco.Web/Search/SearchableTreeAttribute.cs b/src/Umbraco.Web/Search/SearchableTreeAttribute.cs index a2311ae989..d81a85bb4b 100644 --- a/src/Umbraco.Web/Search/SearchableTreeAttribute.cs +++ b/src/Umbraco.Web/Search/SearchableTreeAttribute.cs @@ -1,5 +1,4 @@ using System; -using Umbraco.Core.Exceptions; namespace Umbraco.Web.Search { @@ -8,42 +7,47 @@ namespace Umbraco.Web.Search { public const int DefaultSortOrder = 1000; - /// - /// This constructor defines both the angular service and method name to use - /// - /// - /// - public SearchableTreeAttribute(string serviceName, string methodName) : this(serviceName, methodName, DefaultSortOrder) { } - - /// - /// This constructor defines both the angular service and method name to use and explicitly defines a sort order for the results - /// - /// - /// - /// - public SearchableTreeAttribute(string serviceName, string methodName, int sortOrder) - { - if (string.IsNullOrWhiteSpace(serviceName)) throw new ArgumentNullOrEmptyException(nameof(serviceName)); - if (string.IsNullOrWhiteSpace(methodName)) throw new ArgumentNullOrEmptyException(nameof(methodName)); - MethodName = methodName; - SortOrder = sortOrder; - ServiceName = serviceName; - } - - /// - /// This constructor will assume that the method name equals `format(searchResult, appAlias, treeAlias)` - /// - /// - public SearchableTreeAttribute(string serviceName) - { - if (string.IsNullOrWhiteSpace(serviceName)) throw new ArgumentNullOrEmptyException(nameof(serviceName)); - MethodName = ""; - ServiceName = serviceName; - SortOrder = DefaultSortOrder; - } + public string ServiceName { get; } public string MethodName { get; } - public string ServiceName { get; } + public int SortOrder { get; } + + /// + /// This constructor will assume that the method name equals `format(searchResult, appAlias, treeAlias)`. + /// + /// Name of the service. + public SearchableTreeAttribute(string serviceName) + : this(serviceName, string.Empty) + { } + + /// + /// This constructor defines both the Angular service and method name to use. + /// + /// Name of the service. + /// Name of the method. + public SearchableTreeAttribute(string serviceName, string methodName) + : this(serviceName, methodName, DefaultSortOrder) + { } + + /// + /// This constructor defines both the Angular service and method name to use and explicitly defines a sort order for the results + /// + /// Name of the service. + /// Name of the method. + /// The sort order. + /// serviceName + /// or + /// methodName + /// Value can't be empty or consist only of white-space characters. - serviceName + public SearchableTreeAttribute(string serviceName, string methodName, int sortOrder) + { + if (serviceName == null) throw new ArgumentNullException(nameof(serviceName)); + if (string.IsNullOrWhiteSpace(serviceName)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(serviceName)); + + ServiceName = serviceName; + MethodName = methodName ?? throw new ArgumentNullException(nameof(methodName)); + SortOrder = sortOrder; + } } } diff --git a/src/Umbraco.Web/Search/UmbracoIndexesCreator.cs b/src/Umbraco.Web/Search/UmbracoIndexesCreator.cs index 5ac5529be5..ec536b9d75 100644 --- a/src/Umbraco.Web/Search/UmbracoIndexesCreator.cs +++ b/src/Umbraco.Web/Search/UmbracoIndexesCreator.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Umbraco.Core.Logging; using Umbraco.Core.Services; using Umbraco.Examine; @@ -19,18 +20,20 @@ namespace Umbraco.Web.Search public UmbracoIndexesCreator(IProfilingLogger profilingLogger, ILocalizationService languageService, IPublicAccessService publicAccessService, - IMemberService memberService) + IMemberService memberService, IUmbracoIndexConfig umbracoIndexConfig) { ProfilingLogger = profilingLogger ?? throw new System.ArgumentNullException(nameof(profilingLogger)); LanguageService = languageService ?? throw new System.ArgumentNullException(nameof(languageService)); PublicAccessService = publicAccessService ?? throw new System.ArgumentNullException(nameof(publicAccessService)); MemberService = memberService ?? throw new System.ArgumentNullException(nameof(memberService)); + UmbracoIndexConfig = umbracoIndexConfig; } protected IProfilingLogger ProfilingLogger { get; } protected ILocalizationService LanguageService { get; } protected IPublicAccessService PublicAccessService { get; } protected IMemberService MemberService { get; } + protected IUmbracoIndexConfig UmbracoIndexConfig { get; } /// /// Creates the Umbraco indexes @@ -55,7 +58,8 @@ namespace Umbraco.Web.Search new CultureInvariantWhitespaceAnalyzer(), ProfilingLogger, LanguageService, - GetContentValueSetValidator()); + GetContentValueSetValidator() + ); return index; } @@ -80,28 +84,31 @@ namespace Umbraco.Web.Search CreateFileSystemLuceneDirectory(Constants.UmbracoIndexes.MembersIndexPath), new CultureInvariantWhitespaceAnalyzer(), ProfilingLogger, - GetMemberValueSetValidator()); + GetMemberValueSetValidator() + ); return index; } - + [Obsolete("This method should not be used and will be removed in future versions. GetContentValueSetValidator was moved to IUmbracoIndexConfig")] public virtual IContentValueSetValidator GetContentValueSetValidator() { - return new ContentValueSetValidator(false, true, PublicAccessService); + return UmbracoIndexConfig.GetContentValueSetValidator(); } - + [Obsolete("This method should not be used and will be removed in future versions. GetPublishedContentValueSetValidator was moved to IUmbracoIndexConfig")] public virtual IContentValueSetValidator GetPublishedContentValueSetValidator() { - return new ContentValueSetValidator(true, false, PublicAccessService); + return UmbracoIndexConfig.GetPublishedContentValueSetValidator(); } /// /// Returns the for the member indexer /// /// + [Obsolete("This method should not be used and will be removed in future versions. GetMemberValueSetValidator was moved to IUmbracoIndexConfig")] public virtual IValueSetValidator GetMemberValueSetValidator() { - return new MemberValueSetValidator(); + return UmbracoIndexConfig.GetMemberValueSetValidator(); } + } } diff --git a/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs b/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs index 729b2c0a9d..146177f86f 100644 --- a/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs +++ b/src/Umbraco.Web/Search/UmbracoTreeSearcher.cs @@ -7,6 +7,8 @@ using Examine; using Umbraco.Core; using Umbraco.Core.Mapping; using Umbraco.Core.Models; +using Umbraco.Core.Models.Entities; +using Umbraco.Core.Persistence; using Umbraco.Core.Services; using Umbraco.Examine; using Umbraco.Web.Models.ContentEditing; @@ -14,6 +16,7 @@ using Umbraco.Web.Trees; namespace Umbraco.Web.Search { + /// /// Used for internal Umbraco implementations of /// @@ -24,22 +27,28 @@ namespace Umbraco.Web.Search private readonly ILocalizationService _languageService; private readonly IEntityService _entityService; private readonly UmbracoMapper _mapper; + private readonly ISqlContext _sqlContext; + private readonly IUmbracoTreeSearcherFields _umbracoTreeSearcherFields; + public UmbracoTreeSearcher(IExamineManager examineManager, UmbracoContext umbracoContext, ILocalizationService languageService, IEntityService entityService, - UmbracoMapper mapper) + UmbracoMapper mapper, + ISqlContext sqlContext,IUmbracoTreeSearcherFields umbracoTreeSearcherFields) { _examineManager = examineManager ?? throw new ArgumentNullException(nameof(examineManager)); _umbracoContext = umbracoContext; _languageService = languageService; _entityService = entityService; _mapper = mapper; + _sqlContext = sqlContext; + _umbracoTreeSearcherFields = umbracoTreeSearcherFields; } /// - /// Searches for results based on the entity type + /// Searches Examine for results based on the entity type /// /// /// @@ -61,7 +70,7 @@ namespace Umbraco.Web.Search string type; var indexName = Constants.UmbracoIndexes.InternalIndexName; - var fields = new[] { "id", "__NodeId" }; + var fields = _umbracoTreeSearcherFields.GetBackOfficeFields().ToList(); // TODO: WE should try to allow passing in a lucene raw query, however we will still need to do some manual string // manipulation for things like start paths, member types, etc... @@ -70,12 +79,18 @@ namespace Umbraco.Web.Search //} + //special GUID check since if a user searches on one specifically we need to escape it + if (Guid.TryParse(query, out var g)) + { + query = "\"" + g.ToString() + "\""; + } + switch (entityType) { case UmbracoEntityTypes.Member: indexName = Constants.UmbracoIndexes.MembersIndexName; type = "member"; - fields = new[] { "id", "__NodeId", "email", "loginName" }; + fields.AddRange(_umbracoTreeSearcherFields.GetBackOfficeMembersFields()); if (searchFrom != null && searchFrom != Constants.Conventions.MemberTypes.AllMembersListId && searchFrom.Trim() != "-1") { sb.Append("+__NodeTypeAlias:"); @@ -85,11 +100,13 @@ namespace Umbraco.Web.Search break; case UmbracoEntityTypes.Media: type = "media"; + fields.AddRange(_umbracoTreeSearcherFields.GetBackOfficeMediaFields()); var allMediaStartNodes = _umbracoContext.Security.CurrentUser.CalculateMediaStartNodeIds(_entityService); AppendPath(sb, UmbracoObjectTypes.Media, allMediaStartNodes, searchFrom, ignoreUserStartNodes, _entityService); break; case UmbracoEntityTypes.Document: type = "content"; + fields.AddRange(_umbracoTreeSearcherFields.GetBackOfficeDocumentFields()); var allContentStartNodes = _umbracoContext.Security.CurrentUser.CalculateContentStartNodeIds(_entityService); AppendPath(sb, UmbracoObjectTypes.Document, allContentStartNodes, searchFrom, ignoreUserStartNodes, _entityService); break; @@ -129,7 +146,27 @@ namespace Umbraco.Web.Search } } - private bool BuildQuery(StringBuilder sb, string query, string searchFrom, string[] fields, string type) + /// + /// Searches with the for results based on the entity type + /// + /// + /// + /// + /// + /// + /// + /// + public IEnumerable EntitySearch(UmbracoObjectTypes objectType, string query, int pageSize, long pageIndex, out long totalFound, string searchFrom = null) + { + //if it's a GUID, match it + Guid.TryParse(query, out var g); + + var results = _entityService.GetPagedDescendants(objectType, pageIndex, pageSize, out totalFound, + filter: _sqlContext.Query().Where(x => x.Name.Contains(query) || x.Key == g)); + return _mapper.MapEnumerable(results); + } + + private bool BuildQuery(StringBuilder sb, string query, string searchFrom, List fields, string type) { //build a lucene query: // the nodeName will be boosted 10x without wildcards @@ -202,11 +239,26 @@ namespace Umbraco.Web.Search foreach (var f in fields) { + var queryWordsReplaced = new string[querywords.Length]; + + // when searching file names containing hyphens we need to replace the hyphens with spaces + if (f.Equals(UmbracoExamineIndex.UmbracoFileFieldName)) + { + for (var index = 0; index < querywords.Length; index++) + { + queryWordsReplaced[index] = querywords[index].Replace("\\-", " ").Replace("_", " ").Trim(" "); + } + } + else + { + queryWordsReplaced = querywords; + } + //additional fields normally sb.Append(f); sb.Append(":"); sb.Append("("); - foreach (var w in querywords) + foreach (var w in queryWordsReplaced) { sb.Append(w.ToLower()); sb.Append("* "); @@ -384,19 +436,7 @@ namespace Umbraco.Web.Search /// /// private IEnumerable MediaFromSearchResults(IEnumerable results) - { - //add additional data - foreach (var result in results) - { - var m = _mapper.Map(result); - //if no icon could be mapped, it will be set to document, so change it to picture - if (m.Icon == Constants.Icons.DefaultIcon) - { - m.Icon = Constants.Icons.MediaImage; - } - yield return m; - } - } + => _mapper.Map>(results); /// /// Returns a collection of entities for content based on search results diff --git a/src/Umbraco.Web/Search/UmbracoTreeSearcherFields.cs b/src/Umbraco.Web/Search/UmbracoTreeSearcherFields.cs new file mode 100644 index 0000000000..f90d7bc6b6 --- /dev/null +++ b/src/Umbraco.Web/Search/UmbracoTreeSearcherFields.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using Umbraco.Examine; + +namespace Umbraco.Web.Search +{ + public class UmbracoTreeSearcherFields : IUmbracoTreeSearcherFields + { + private IReadOnlyList _backOfficeFields = new List {"id", "__NodeId", "__Key"}; + public IEnumerable GetBackOfficeFields() + { + return _backOfficeFields; + } + + + private IReadOnlyList _backOfficeMembersFields = new List {"email", "loginName"}; + public IEnumerable GetBackOfficeMembersFields() + { + return _backOfficeMembersFields; + } + private IReadOnlyList _backOfficeMediaFields = new List {UmbracoExamineIndex.UmbracoFileFieldName }; + public IEnumerable GetBackOfficeMediaFields() + { + return _backOfficeMediaFields; + } + public IEnumerable GetBackOfficeDocumentFields() + { + return Enumerable.Empty(); + } + } +} diff --git a/src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs b/src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs index 33aabbaf94..568bfc1a26 100644 --- a/src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs +++ b/src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs @@ -75,7 +75,8 @@ namespace Umbraco.Web.Security /// public static void ForUmbracoBackOffice(this AuthenticationOptions options, string style, string icon, string callbackPath = null) { - if (string.IsNullOrEmpty(options.AuthenticationType)) throw new ArgumentNullOrEmptyException("options.AuthenticationType"); + if (options == null) throw new ArgumentNullException(nameof(options)); + if (string.IsNullOrEmpty(options.AuthenticationType)) throw new InvalidOperationException("The authentication type can't be null or empty."); //Ensure the prefix is set if (options.AuthenticationType.StartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix) == false) @@ -101,7 +102,7 @@ namespace Umbraco.Web.Security var path = (PathString) prop.GetValue(options); if (path.HasValue) { - UmbracoModule.ReservedPaths.TryAdd(path.ToString()); + RoutableDocumentFilter.ReservedPaths.TryAdd(path.ToString()); } } } @@ -112,7 +113,7 @@ namespace Umbraco.Web.Security } else { - UmbracoModule.ReservedPaths.TryAdd(callbackPath); + RoutableDocumentFilter.ReservedPaths.TryAdd(callbackPath); } } } diff --git a/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs index e7aef4be03..ed6abb3fbe 100644 --- a/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Web/Security/BackOfficeCookieAuthenticationProvider.cs @@ -41,6 +41,9 @@ namespace Umbraco.Web.Security : Guid.NewGuid(); backOfficeIdentity.SessionId = session.ToString(); + + //since it is a cookie-based authentication add that claim + backOfficeIdentity.AddClaim(new Claim(ClaimTypes.CookiePath, "/", ClaimValueTypes.String, UmbracoBackOfficeIdentity.Issuer, UmbracoBackOfficeIdentity.Issuer, backOfficeIdentity)); } base.ResponseSignIn(context); diff --git a/src/Umbraco.Web/Suspendable.cs b/src/Umbraco.Web/Suspendable.cs index 86c83120ea..cfe4d28e8b 100644 --- a/src/Umbraco.Web/Suspendable.cs +++ b/src/Umbraco.Web/Suspendable.cs @@ -50,6 +50,8 @@ namespace Umbraco.Web } } + //This is really needed at all since the only place this is used is in ExamineComponent and that already maintains a flag of whether it suspsended or not + // AHH... but Deploy probably uses this? public static class ExamineEvents { private static bool _tried, _suspended; diff --git a/src/Umbraco.Web/Templates/HtmlImageSourceParser.cs b/src/Umbraco.Web/Templates/HtmlImageSourceParser.cs new file mode 100644 index 0000000000..dcc5318b89 --- /dev/null +++ b/src/Umbraco.Web/Templates/HtmlImageSourceParser.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Umbraco.Core; + +namespace Umbraco.Web.Templates +{ + + public sealed class HtmlImageSourceParser + { + public HtmlImageSourceParser(Func getMediaUrl) + { + this._getMediaUrl = getMediaUrl; + } + + public HtmlImageSourceParser(IUmbracoContextAccessor umbracoContextAccessor) + { + if (umbracoContextAccessor?.UmbracoContext?.UrlProvider == null) + { + return; + } + + _getMediaUrl = (guid) => umbracoContextAccessor.UmbracoContext.UrlProvider.GetMediaUrl(guid); + } + + private static readonly Regex ResolveImgPattern = new Regex(@"(]*src="")([^""\?]*)((?:\?[^""]*)?""[^>]*data-udi="")([^""]*)(""[^>]*>)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); + + private static readonly Regex DataUdiAttributeRegex = new Regex(@"data-udi=\\?(?:""|')(?umb://[A-z0-9\-]+/[A-z0-9]+)\\?(?:""|')", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + private readonly Func _getMediaUrl; + + /// + /// Parses out media UDIs from an html string based on 'data-udi' html attributes + /// + /// + /// + public IEnumerable FindUdisFromDataAttributes(string text) + { + var matches = DataUdiAttributeRegex.Matches(text); + if (matches.Count == 0) + yield break; + + foreach (Match match in matches) + { + if (match.Groups.Count == 2 && Udi.TryParse(match.Groups[1].Value, out var udi)) + yield return udi; + } + } + + /// + /// Parses the string looking for Umbraco image tags and updates them to their up-to-date image sources. + /// + /// + /// + /// Umbraco image tags are identified by their data-udi attributes + public string EnsureImageSources(string text) + { + // no point in doing any processing if we don't have + // a function to retrieve Urls + if (_getMediaUrl == null) + { + return text; + } + + return ResolveImgPattern.Replace(text, match => + { + // match groups: + // - 1 = from the beginning of the image tag until src attribute value begins + // - 2 = the src attribute value excluding the querystring (if present) + // - 3 = anything after group 2 and before the data-udi attribute value begins + // - 4 = the data-udi attribute value + // - 5 = anything after group 4 until the image tag is closed + var udi = match.Groups[4].Value; + if (udi.IsNullOrWhiteSpace() || GuidUdi.TryParse(udi, out var guidUdi) == false) + { + return match.Value; + } + var mediaUrl = _getMediaUrl(guidUdi.Guid); + if (mediaUrl == null) + { + // image does not exist - we could choose to remove the image entirely here (return empty string), + // but that would leave the editors completely in the dark as to why the image doesn't show + return match.Value; + } + + return $"{match.Groups[1].Value}{mediaUrl}{match.Groups[3].Value}{udi}{match.Groups[5].Value}"; + }); + } + + /// + /// Removes media urls from <img> tags where a data-udi attribute is present + /// + /// + /// + public string RemoveImageSources(string text) + // see comment in ResolveMediaFromTextString for group reference + => ResolveImgPattern.Replace(text, "$1$3$4$5"); + } +} diff --git a/src/Umbraco.Web/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Web/Templates/HtmlLocalLinkParser.cs new file mode 100644 index 0000000000..f65a7183b7 --- /dev/null +++ b/src/Umbraco.Web/Templates/HtmlLocalLinkParser.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Umbraco.Core; +using Umbraco.Core.Logging; +using Umbraco.Web.PublishedCache; +using Umbraco.Web.Routing; + +namespace Umbraco.Web.Templates +{ + /// + /// Utility class used to parse internal links + /// + public sealed class HtmlLocalLinkParser + { + + private static readonly Regex LocalLinkPattern = new Regex(@"href=""[/]?(?:\{|\%7B)localLink:([a-zA-Z0-9-://]+)(?:\}|\%7D)", + RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); + + private readonly IUmbracoContextAccessor _umbracoContextAccessor; + + public HtmlLocalLinkParser(IUmbracoContextAccessor umbracoContextAccessor) + { + _umbracoContextAccessor = umbracoContextAccessor; + } + + internal IEnumerable FindUdisFromLocalLinks(string text) + { + foreach ((int? intId, GuidUdi udi, string tagValue) in FindLocalLinkIds(text)) + { + if (udi != null) + yield return udi; // In v8, we only care abuot UDIs + } + } + + /// + /// Parses the string looking for the {localLink} syntax and updates them to their correct links. + /// + /// + /// + /// + public string EnsureInternalLinks(string text, bool preview) + { + if (_umbracoContextAccessor.UmbracoContext == null) + throw new InvalidOperationException("Could not parse internal links, there is no current UmbracoContext"); + + if (!preview) + return EnsureInternalLinks(text); + + using (_umbracoContextAccessor.UmbracoContext.ForcedPreview(preview)) // force for url provider + { + return EnsureInternalLinks(text); + } + } + + /// + /// Parses the string looking for the {localLink} syntax and updates them to their correct links. + /// + /// + /// + /// + public string EnsureInternalLinks(string text) + { + if (_umbracoContextAccessor.UmbracoContext == null) + throw new InvalidOperationException("Could not parse internal links, there is no current UmbracoContext"); + + var urlProvider = _umbracoContextAccessor.UmbracoContext.UrlProvider; + + foreach((int? intId, GuidUdi udi, string tagValue) in FindLocalLinkIds(text)) + { + if (udi != null) + { + var newLink = "#"; + if (udi.EntityType == Constants.UdiEntityType.Document) + newLink = urlProvider.GetUrl(udi.Guid); + else if (udi.EntityType == Constants.UdiEntityType.Media) + newLink = urlProvider.GetMediaUrl(udi.Guid); + + if (newLink == null) + newLink = "#"; + + text = text.Replace(tagValue, "href=\"" + newLink); + } + else if (intId.HasValue) + { + var newLink = urlProvider.GetUrl(intId.Value); + text = text.Replace(tagValue, "href=\"" + newLink); + } + } + + return text; + } + + private IEnumerable<(int? intId, GuidUdi udi, string tagValue)> FindLocalLinkIds(string text) + { + // Parse internal links + var tags = LocalLinkPattern.Matches(text); + foreach (Match tag in tags) + { + if (tag.Groups.Count > 0) + { + var id = tag.Groups[1].Value; //.Remove(tag.Groups[1].Value.Length - 1, 1); + + //The id could be an int or a UDI + if (Udi.TryParse(id, out var udi)) + { + var guidUdi = udi as GuidUdi; + if (guidUdi != null) + yield return (null, guidUdi, tag.Value); + } + + if (int.TryParse(id, out var intId)) + { + yield return (intId, null, tag.Value); + } + } + } + + } + } +} diff --git a/src/Umbraco.Web/Templates/HtmlUrlParser.cs b/src/Umbraco.Web/Templates/HtmlUrlParser.cs new file mode 100644 index 0000000000..5b78477579 --- /dev/null +++ b/src/Umbraco.Web/Templates/HtmlUrlParser.cs @@ -0,0 +1,61 @@ +using System.Text.RegularExpressions; +using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.IO; +using Umbraco.Core.Logging; + +namespace Umbraco.Web.Templates +{ + public sealed class HtmlUrlParser + { + private readonly IContentSection _contentSection; + private readonly IProfilingLogger _logger; + + private static readonly Regex ResolveUrlPattern = new Regex("(=[\"\']?)(\\W?\\~(?:.(?![\"\']?\\s+(?:\\S+)=|[>\"\']))+.)[\"\']?", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); + + public HtmlUrlParser(IContentSection contentSection, IProfilingLogger logger) + { + _contentSection = contentSection; + _logger = logger; + } + + /// + /// The RegEx matches any HTML attribute values that start with a tilde (~), those that match are passed to ResolveUrl to replace the tilde with the application path. + /// + /// + /// + /// + /// When used with a Virtual-Directory set-up, this would resolve all URLs correctly. + /// The recommendation is that the "ResolveUrlsFromTextString" option (in umbracoSettings.config) is set to false for non-Virtual-Directory installs. + /// + public string EnsureUrls(string text) + { + if (_contentSection.ResolveUrlsFromTextString == false) return text; + + using (var timer = _logger.DebugDuration(typeof(IOHelper), "ResolveUrlsFromTextString starting", "ResolveUrlsFromTextString complete")) + { + // find all relative urls (ie. urls that contain ~) + var tags = ResolveUrlPattern.Matches(text); + _logger.Debug(typeof(IOHelper), "After regex: {Duration} matched: {TagsCount}", timer.Stopwatch.ElapsedMilliseconds, tags.Count); + foreach (Match tag in tags) + { + var url = ""; + if (tag.Groups[1].Success) + url = tag.Groups[1].Value; + + // The richtext editor inserts a slash in front of the url. That's why we need this little fix + // if (url.StartsWith("/")) + // text = text.Replace(url, ResolveUrl(url.Substring(1))); + // else + if (string.IsNullOrEmpty(url) == false) + { + var resolvedUrl = (url.Substring(0, 1) == "/") ? IOHelper.ResolveUrl(url.Substring(1)) : IOHelper.ResolveUrl(url); + text = text.Replace(url, resolvedUrl); + } + } + } + + return text; + } + } +} diff --git a/src/Umbraco.Web/Templates/TemplateUtilities.cs b/src/Umbraco.Web/Templates/TemplateUtilities.cs index b1d9947b9b..f796985d39 100644 --- a/src/Umbraco.Web/Templates/TemplateUtilities.cs +++ b/src/Umbraco.Web/Templates/TemplateUtilities.cs @@ -1,22 +1,25 @@ -using System; -using System.Text.RegularExpressions; +using HtmlAgilityPack; +using System; +using System.Collections.Generic; +using System.IO; using Umbraco.Core; -using Umbraco.Core.Configuration; using Umbraco.Core.IO; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.Services; using Umbraco.Web.Composing; +using Umbraco.Web.PropertyEditors; using Umbraco.Web.PublishedCache; using Umbraco.Web.Routing; +using File = System.IO.File; namespace Umbraco.Web.Templates { - //NOTE: I realize there is only one class in this namespace but I'm pretty positive that there will be more classes in - //this namespace once we start migrating and cleaning up more code. - /// - /// Utility class used for templates - /// + [Obsolete("This class is obsolete, all methods have been moved to other classes: " + nameof(HtmlLocalLinkParser) + ", " + nameof(HtmlUrlParser) + " and " + nameof(HtmlImageSourceParser))] public static class TemplateUtilities { + [Obsolete("Inject and use an instance of " + nameof(HtmlLocalLinkParser) + " instead")] internal static string ParseInternalLinks(string text, bool preview, UmbracoContext umbracoContext) { using (umbracoContext.ForcedPreview(preview)) // force for url provider @@ -27,161 +30,28 @@ namespace Umbraco.Web.Templates return text; } - /// - /// Parses the string looking for the {localLink} syntax and updates them to their correct links. - /// - /// - /// - /// - public static string ParseInternalLinks(string text, UrlProvider urlProvider) => - ParseInternalLinks(text, urlProvider, Current.UmbracoContext.MediaCache); + [Obsolete("Inject and use an instance of " + nameof(HtmlLocalLinkParser) + " instead")] + public static string ParseInternalLinks(string text, UrlProvider urlProvider) + => Current.Factory.GetInstance().EnsureInternalLinks(text); - // TODO: Replace mediaCache with media url provider - internal static string ParseInternalLinks(string text, UrlProvider urlProvider, IPublishedMediaCache mediaCache) - { - if (urlProvider == null) throw new ArgumentNullException(nameof(urlProvider)); - if (mediaCache == null) throw new ArgumentNullException(nameof(mediaCache)); - - // Parse internal links - var tags = LocalLinkPattern.Matches(text); - foreach (Match tag in tags) - { - if (tag.Groups.Count > 0) - { - var id = tag.Groups[1].Value; //.Remove(tag.Groups[1].Value.Length - 1, 1); - - //The id could be an int or a UDI - if (Udi.TryParse(id, out var udi)) - { - var guidUdi = udi as GuidUdi; - if (guidUdi != null) - { - var newLink = "#"; - if (guidUdi.EntityType == Constants.UdiEntityType.Document) - newLink = urlProvider.GetUrl(guidUdi.Guid); - else if (guidUdi.EntityType == Constants.UdiEntityType.Media) - newLink = mediaCache.GetById(guidUdi.Guid)?.Url; - - if (newLink == null) - newLink = "#"; - - text = text.Replace(tag.Value, "href=\"" + newLink); - } - } - - if (int.TryParse(id, out var intId)) - { - var newLink = urlProvider.GetUrl(intId); - text = text.Replace(tag.Value, "href=\"" + newLink); - } - } - } - - return text; - } - - - // static compiled regex for faster performance - private static readonly Regex LocalLinkPattern = new Regex(@"href=""[/]?(?:\{|\%7B)localLink:([a-zA-Z0-9-://]+)(?:\}|\%7D)", - RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); - - private static readonly Regex ResolveUrlPattern = new Regex("(=[\"\']?)(\\W?\\~(?:.(?![\"\']?\\s+(?:\\S+)=|[>\"\']))+.)[\"\']?", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); - - private static readonly Regex ResolveImgPattern = new Regex(@"(]*src="")([^""\?]*)([^""]*""[^>]*data-udi="")([^""]*)(""[^>]*>)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); - - /// - /// The RegEx matches any HTML attribute values that start with a tilde (~), those that match are passed to ResolveUrl to replace the tilde with the application path. - /// - /// - /// - /// - /// When used with a Virtual-Directory set-up, this would resolve all URLs correctly. - /// The recommendation is that the "ResolveUrlsFromTextString" option (in umbracoSettings.config) is set to false for non-Virtual-Directory installs. - /// + [Obsolete("Inject and use an instance of " + nameof(HtmlUrlParser))] public static string ResolveUrlsFromTextString(string text) - { - if (Current.Configs.Settings().Content.ResolveUrlsFromTextString == false) return text; - - using (var timer = Current.ProfilingLogger.DebugDuration(typeof(IOHelper), "ResolveUrlsFromTextString starting", "ResolveUrlsFromTextString complete")) - { - // find all relative urls (ie. urls that contain ~) - var tags = ResolveUrlPattern.Matches(text); - Current.Logger.Debug(typeof(IOHelper), "After regex: {Duration} matched: {TagsCount}", timer.Stopwatch.ElapsedMilliseconds, tags.Count); - foreach (Match tag in tags) - { - var url = ""; - if (tag.Groups[1].Success) - url = tag.Groups[1].Value; - - // The richtext editor inserts a slash in front of the url. That's why we need this little fix - // if (url.StartsWith("/")) - // text = text.Replace(url, ResolveUrl(url.Substring(1))); - // else - if (String.IsNullOrEmpty(url) == false) - { - var resolvedUrl = (url.Substring(0, 1) == "/") ? IOHelper.ResolveUrl(url.Substring(1)) : IOHelper.ResolveUrl(url); - text = text.Replace(url, resolvedUrl); - } - } - } - - return text; - } + => Current.Factory.GetInstance().EnsureUrls(text); + [Obsolete("Use " + nameof(StringExtensions) + "." + nameof(StringExtensions.CleanForXss) + " instead")] public static string CleanForXss(string text, params char[] ignoreFromClean) - { - return text.CleanForXss(ignoreFromClean); - } + => text.CleanForXss(ignoreFromClean); - /// - /// Parses the string looking for Umbraco image tags and updates them to their up-to-date image sources. - /// - /// - /// - /// Umbraco image tags are identified by their data-udi attributes + [Obsolete("Use " + nameof(HtmlImageSourceParser) + "." + nameof(HtmlImageSourceParser.EnsureImageSources) + " instead")] public static string ResolveMediaFromTextString(string text) - { - // don't attempt to proceed without a context - if (Current.UmbracoContext == null || Current.UmbracoContext.Media == null) - { - return text; - } - - return ResolveImgPattern.Replace(text, match => - { - // match groups: - // - 1 = from the beginning of the image tag until src attribute value begins - // - 2 = the src attribute value excluding the querystring (if present) - // - 3 = anything after group 2 and before the data-udi attribute value begins - // - 4 = the data-udi attribute value - // - 5 = anything after group 4 until the image tag is closed - var udi = match.Groups[4].Value; - if(udi.IsNullOrWhiteSpace() || GuidUdi.TryParse(udi, out var guidUdi) == false) - { - return match.Value; - } - var media = Current.UmbracoContext.Media.GetById(guidUdi.Guid); - if(media == null) - { - // image does not exist - we could choose to remove the image entirely here (return empty string), - // but that would leave the editors completely in the dark as to why the image doesn't show - return match.Value; - } - - var url = media.Url; - return $"{match.Groups[1].Value}{url}{match.Groups[3].Value}{udi}{match.Groups[5].Value}"; - }); - } - - /// - /// Removes media urls from <img> tags where a data-udi attribute is present - /// - /// - /// + => Current.Factory.GetInstance().EnsureImageSources(text); + + [Obsolete("Use " + nameof(HtmlImageSourceParser) + "." + nameof(HtmlImageSourceParser.RemoveImageSources) + " instead")] internal static string RemoveMediaUrlsFromTextString(string text) - // see comment in ResolveMediaFromTextString for group reference - => ResolveImgPattern.Replace(text, "$1$3$4$5"); + => Current.Factory.GetInstance().RemoveImageSources(text); + + [Obsolete("Use " + nameof(HtmlImageSourceParser) + "." + nameof(RichTextEditorPastedImages.FindAndPersistPastedTempImages) + " instead")] + internal static string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId, IMediaService mediaService, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, ILogger logger) + => Current.Factory.GetInstance().FindAndPersistPastedTempImages(html, mediaParentFolder, userId); } } diff --git a/src/Umbraco.Web/Trees/ContentBlueprintTreeController.cs b/src/Umbraco.Web/Trees/ContentBlueprintTreeController.cs index ac75fd831d..fd05f7cfbd 100644 --- a/src/Umbraco.Web/Trees/ContentBlueprintTreeController.cs +++ b/src/Umbraco.Web/Trees/ContentBlueprintTreeController.cs @@ -47,7 +47,7 @@ namespace Umbraco.Web.Trees if (id == Constants.System.RootString) { //get all blueprint content types - var contentTypeAliases = entities.Select(x => ((ContentEntitySlim) x).ContentTypeAlias).Distinct(); + var contentTypeAliases = entities.Select(x => ((IContentEntitySlim) x).ContentTypeAlias).Distinct(); //get the ids var contentTypeIds = Services.ContentTypeService.GetAllContentTypeIds(contentTypeAliases.ToArray()).ToArray(); @@ -75,7 +75,7 @@ namespace Umbraco.Web.Trees var ct = Services.ContentTypeService.Get(intId.Result); if (ct == null) return nodes; - var blueprintsForDocType = entities.Where(x => ct.Alias == ((ContentEntitySlim) x).ContentTypeAlias); + var blueprintsForDocType = entities.Where(x => ct.Alias == ((IContentEntitySlim) x).ContentTypeAlias); nodes.AddRange(blueprintsForDocType .Select(entity => { diff --git a/src/Umbraco.Web/Trees/ContentTreeController.cs b/src/Umbraco.Web/Trees/ContentTreeController.cs index 970191e510..85ff30487d 100644 --- a/src/Umbraco.Web/Trees/ContentTreeController.cs +++ b/src/Umbraco.Web/Trees/ContentTreeController.cs @@ -15,6 +15,10 @@ using Umbraco.Web.WebApi.Filters; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Search; using Constants = Umbraco.Core.Constants; +using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; +using Umbraco.Core.Persistence; namespace Umbraco.Web.Trees { @@ -36,20 +40,21 @@ namespace Umbraco.Web.Trees private readonly UmbracoTreeSearcher _treeSearcher; private readonly ActionCollection _actions; - public ContentTreeController(UmbracoTreeSearcher treeSearcher, ActionCollection actions) - { - _treeSearcher = treeSearcher; - _actions = actions; - } - protected override int RecycleBinId => Constants.System.RecycleBinContent; protected override bool RecycleBinSmells => Services.ContentService.RecycleBinSmells(); private int[] _userStartNodes; + protected override int[] UserStartNodes => _userStartNodes ?? (_userStartNodes = Security.CurrentUser.CalculateContentStartNodeIds(Services.EntityService)); + public ContentTreeController(UmbracoTreeSearcher treeSearcher, ActionCollection actions, IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) + { + _treeSearcher = treeSearcher; + _actions = actions; + } + /// protected override TreeNode GetSingleTreeNode(IEntitySlim entity, string parentId, FormDataCollection queryStrings) { @@ -124,8 +129,8 @@ namespace Umbraco.Web.Trees menu.DefaultMenuAlias = ActionNew.ActionAlias; // we need to get the default permissions as you can't set permissions on the very root node - var permission = Services.UserService.GetPermissions(Security.CurrentUser, Constants.System.Root).First(); - var nodeActions = _actions.FromEntityPermission(permission) + var assignedPermissions = Services.UserService.GetAssignedPermissions(Security.CurrentUser, Constants.System.Root); + var nodeActions = _actions.GetByLetters(assignedPermissions) .Select(x => new MenuItem(x)); //these two are the standard items diff --git a/src/Umbraco.Web/Trees/ContentTreeControllerBase.cs b/src/Umbraco.Web/Trees/ContentTreeControllerBase.cs index 015c91cb81..1069df0ec4 100644 --- a/src/Umbraco.Web/Trees/ContentTreeControllerBase.cs +++ b/src/Umbraco.Web/Trees/ContentTreeControllerBase.cs @@ -18,11 +18,23 @@ using System.Web.Http.ModelBinding; using Umbraco.Web.Actions; using Umbraco.Web.Composing; using Umbraco.Core.Security; +using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; +using Umbraco.Core.Persistence; namespace Umbraco.Web.Trees { public abstract class ContentTreeControllerBase : TreeController { + + protected ContentTreeControllerBase(IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) + { + } + + protected ContentTreeControllerBase() + { + } + #region Actions /// @@ -183,20 +195,30 @@ namespace Umbraco.Web.Trees //get the current user start node/paths GetUserStartNodes(out var userStartNodes, out var userStartNodePaths); - nodes.AddRange(entities.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)).Where(x => x != null)); - // if the user does not have access to the root node, what we have is the start nodes, - // but to provide some context we also need to add their topmost nodes when they are not + // but to provide some context we need to add their topmost nodes when they are not // topmost nodes themselves (level > 1). if (id == rootIdString && hasAccessToRoot == false) { - var topNodeIds = entities.Where(x => x.Level > 1).Select(GetTopNodeId).Where(x => x != 0).Distinct().ToArray(); + // first add the entities that are topmost to the nodes collection + var topMostEntities = entities.Where(x => x.Level == 1).ToArray(); + nodes.AddRange(topMostEntities.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)).Where(x => x != null)); + + // now add the topmost nodes of the entities that aren't topmost to the nodes collection as well + // - these will appear as "no-access" nodes in the tree, but will allow the editors to drill down through the tree + // until they reach their start nodes + var topNodeIds = entities.Except(topMostEntities).Select(GetTopNodeId).Where(x => x != 0).Distinct().ToArray(); if (topNodeIds.Length > 0) { var topNodes = Services.EntityService.GetAll(UmbracoObjectType, topNodeIds.ToArray()); nodes.AddRange(topNodes.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)).Where(x => x != null)); } } + else + { + // the user has access to the root, just add the entities + nodes.AddRange(entities.Select(x => GetSingleTreeNodeWithAccessCheck(x, id, queryStrings, userStartNodes, userStartNodePaths)).Where(x => x != null)); + } return nodes; } @@ -442,8 +464,8 @@ namespace Umbraco.Web.Trees internal IEnumerable GetAllowedUserMenuItemsForNode(IUmbracoEntity dd) { - var permission = Services.UserService.GetPermissions(Security.CurrentUser, dd.Path); - return Current.Actions.FromEntityPermission(permission).Select(x => new MenuItem(x)); + var permissionsForPath = Services.UserService.GetPermissionsForPath(Security.CurrentUser, dd.Path).GetAllPermissions(); + return Current.Actions.GetByLetters(permissionsForPath).Select(x => new MenuItem(x)); } /// @@ -529,6 +551,8 @@ namespace Umbraco.Web.Trees private bool? _ignoreUserStartNodes; + + /// /// If the request should allows a user to choose nodes that they normally don't have access to /// diff --git a/src/Umbraco.Web/Trees/ContentTypeTreeController.cs b/src/Umbraco.Web/Trees/ContentTypeTreeController.cs index bead6aa141..e8f8fb9f75 100644 --- a/src/Umbraco.Web/Trees/ContentTypeTreeController.cs +++ b/src/Umbraco.Web/Trees/ContentTypeTreeController.cs @@ -3,11 +3,17 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http.Formatting; using Umbraco.Core; +using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; +using Umbraco.Core.Persistence; +using Umbraco.Core.Services; using Umbraco.Web.Actions; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Models.Trees; +using Umbraco.Web.Search; using Umbraco.Web.WebApi.Filters; namespace Umbraco.Web.Trees @@ -18,6 +24,13 @@ namespace Umbraco.Web.Trees [CoreTree] public class ContentTypeTreeController : TreeController, ISearchableTree { + private readonly UmbracoTreeSearcher _treeSearcher; + + public ContentTypeTreeController(UmbracoTreeSearcher treeSearcher, IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) + { + _treeSearcher = treeSearcher; + } + protected override TreeNode CreateRootNode(FormDataCollection queryStrings) { var root = base.CreateRootNode(queryStrings); @@ -49,8 +62,10 @@ namespace Umbraco.Web.Trees //if the request is for folders only then just return if (queryStrings["foldersonly"].IsNullOrWhiteSpace() == false && queryStrings["foldersonly"] == "1") return nodes; + var children = Services.EntityService.GetChildren(intId.Result, UmbracoObjectTypes.DocumentType).ToArray(); + var contentTypes = Services.ContentTypeService.GetAll(children.Select(c => c.Id).ToArray()).ToDictionary(c => c.Id); nodes.AddRange( - Services.EntityService.GetChildren(intId.Result, UmbracoObjectTypes.DocumentType) + children .OrderBy(entity => entity.Name) .Select(dt => { @@ -60,6 +75,12 @@ namespace Umbraco.Web.Trees var node = CreateTreeNode(dt, Constants.ObjectTypes.DocumentType, id, queryStrings, Constants.Icons.ContentType, hasChildren); node.Path = dt.Path; + + // enrich the result with content type data that's not available in the entity service output + var contentType = contentTypes[dt.Id]; + node.Alias = contentType.Alias; + node.AdditionalData["isElement"] = contentType.IsElement; + return node; })); @@ -135,10 +156,7 @@ namespace Umbraco.Web.Trees } public IEnumerable Search(string query, int pageSize, long pageIndex, out long totalFound, string searchFrom = null) - { - var results = Services.EntityService.GetPagedDescendants(UmbracoObjectTypes.DocumentType, pageIndex, pageSize, out totalFound, - filter: SqlContext.Query().Where(x => x.Name.Contains(query))); - return Mapper.MapEnumerable(results); - } + => _treeSearcher.EntitySearch(UmbracoObjectTypes.DocumentType, query, pageSize, pageIndex, out totalFound, searchFrom); + } } diff --git a/src/Umbraco.Web/Trees/DataTypeTreeController.cs b/src/Umbraco.Web/Trees/DataTypeTreeController.cs index 2465b4d45a..6a7fb7f5ad 100644 --- a/src/Umbraco.Web/Trees/DataTypeTreeController.cs +++ b/src/Umbraco.Web/Trees/DataTypeTreeController.cs @@ -12,6 +12,11 @@ using Umbraco.Core.Services; using Umbraco.Web.Actions; using Umbraco.Web.Models.ContentEditing; using Constants = Umbraco.Core.Constants; +using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; +using Umbraco.Core.Persistence; +using Umbraco.Web.Search; namespace Umbraco.Web.Trees { @@ -21,6 +26,13 @@ namespace Umbraco.Web.Trees [CoreTree] public class DataTypeTreeController : TreeController, ISearchableTree { + private readonly UmbracoTreeSearcher _treeSearcher; + + public DataTypeTreeController(UmbracoTreeSearcher treeSearcher, IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) + { + _treeSearcher = treeSearcher; + } + protected override TreeNodeCollection GetTreeNodes(string id, FormDataCollection queryStrings) { var intId = id.TryConvertTo(); @@ -48,19 +60,20 @@ namespace Umbraco.Web.Trees //System ListView nodes var systemListViewDataTypeIds = GetNonDeletableSystemListViewDataTypeIds(); + var children = Services.EntityService.GetChildren(intId.Result, UmbracoObjectTypes.DataType).ToArray(); + var dataTypes = Services.DataTypeService.GetAll(children.Select(c => c.Id).ToArray()).ToDictionary(dt => dt.Id); + nodes.AddRange( - Services.EntityService.GetChildren(intId.Result, UmbracoObjectTypes.DataType) + children .OrderBy(entity => entity.Name) .Select(dt => { - var node = CreateTreeNode(dt.Id.ToInvariantString(), id, queryStrings, dt.Name, Constants.Icons.DataType, false); + var dataType = dataTypes[dt.Id]; + var node = CreateTreeNode(dt.Id.ToInvariantString(), id, queryStrings, dt.Name, dataType.Editor.Icon, false); node.Path = dt.Path; - if (systemListViewDataTypeIds.Contains(dt.Id)) - { - node.Icon = Constants.Icons.ListView; - } return node; - })); + }) + ); return nodes; } @@ -148,10 +161,6 @@ namespace Umbraco.Web.Trees } public IEnumerable Search(string query, int pageSize, long pageIndex, out long totalFound, string searchFrom = null) - { - var results = Services.EntityService.GetPagedDescendants(UmbracoObjectTypes.DataType, pageIndex, pageSize, out totalFound, - filter: SqlContext.Query().Where(x => x.Name.Contains(query))); - return Mapper.MapEnumerable(results); - } + => _treeSearcher.EntitySearch(UmbracoObjectTypes.DataType, query, pageSize, pageIndex, out totalFound, searchFrom); } } diff --git a/src/Umbraco.Web/Trees/MediaTreeController.cs b/src/Umbraco.Web/Trees/MediaTreeController.cs index f4f373f9a4..df44a809c9 100644 --- a/src/Umbraco.Web/Trees/MediaTreeController.cs +++ b/src/Umbraco.Web/Trees/MediaTreeController.cs @@ -16,6 +16,10 @@ using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Search; using Constants = Umbraco.Core.Constants; using Umbraco.Core.Services.Implement; +using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; +using Umbraco.Core.Persistence; namespace Umbraco.Web.Trees { @@ -35,7 +39,7 @@ namespace Umbraco.Web.Trees { private readonly UmbracoTreeSearcher _treeSearcher; - public MediaTreeController(UmbracoTreeSearcher treeSearcher) + public MediaTreeController(UmbracoTreeSearcher treeSearcher, IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) { _treeSearcher = treeSearcher; } diff --git a/src/Umbraco.Web/Trees/MediaTypeTreeController.cs b/src/Umbraco.Web/Trees/MediaTypeTreeController.cs index 53e30a4ee5..3d0046c319 100644 --- a/src/Umbraco.Web/Trees/MediaTypeTreeController.cs +++ b/src/Umbraco.Web/Trees/MediaTypeTreeController.cs @@ -10,6 +10,11 @@ using Umbraco.Web.WebApi.Filters; using Umbraco.Core.Services; using Umbraco.Web.Actions; using Umbraco.Web.Models.ContentEditing; +using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; +using Umbraco.Core.Persistence; +using Umbraco.Web.Search; namespace Umbraco.Web.Trees { @@ -19,6 +24,13 @@ namespace Umbraco.Web.Trees [CoreTree] public class MediaTypeTreeController : TreeController, ISearchableTree { + private readonly UmbracoTreeSearcher _treeSearcher; + + public MediaTypeTreeController(UmbracoTreeSearcher treeSearcher, IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) + { + _treeSearcher = treeSearcher; + } + protected override TreeNodeCollection GetTreeNodes(string id, FormDataCollection queryStrings) { var intId = id.TryConvertTo(); @@ -108,7 +120,10 @@ namespace Umbraco.Web.Trees } menu.Items.Add(Services.TextService, opensDialog: true); - menu.Items.Add(Services.TextService, opensDialog: true); + if(ct.IsSystemMediaType() == false) + { + menu.Items.Add(Services.TextService, opensDialog: true); + } menu.Items.Add(new RefreshNode(Services.TextService, true)); } @@ -117,10 +132,7 @@ namespace Umbraco.Web.Trees } public IEnumerable Search(string query, int pageSize, long pageIndex, out long totalFound, string searchFrom = null) - { - var results = Services.EntityService.GetPagedDescendants(UmbracoObjectTypes.MediaType, pageIndex, pageSize, out totalFound, - filter: SqlContext.Query().Where(x => x.Name.Contains(query))); - return Mapper.MapEnumerable(results); - } + => _treeSearcher.EntitySearch(UmbracoObjectTypes.MediaType, query, pageSize, pageIndex, out totalFound, searchFrom); + } } diff --git a/src/Umbraco.Web/Trees/MemberTreeController.cs b/src/Umbraco.Web/Trees/MemberTreeController.cs index 2657f13255..c0a9d15cfa 100644 --- a/src/Umbraco.Web/Trees/MemberTreeController.cs +++ b/src/Umbraco.Web/Trees/MemberTreeController.cs @@ -131,7 +131,7 @@ namespace Umbraco.Web.Trees { nodes.AddRange(Services.MemberTypeService.GetAll() .Select(memberType => - CreateTreeNode(memberType.Alias, id, queryStrings, memberType.Name, Constants.Icons.MemberType, true, + CreateTreeNode(memberType.Alias, id, queryStrings, memberType.Name, memberType.Icon.IfNullOrWhiteSpace(Constants.Icons.Member), true, queryStrings.GetRequiredValue("application") + TreeAlias.EnsureStartsWith('/') + "/list/" + memberType.Alias))); } } diff --git a/src/Umbraco.Web/Trees/MemberTypeTreeController.cs b/src/Umbraco.Web/Trees/MemberTypeTreeController.cs index bd80f63897..2046baf2d3 100644 --- a/src/Umbraco.Web/Trees/MemberTypeTreeController.cs +++ b/src/Umbraco.Web/Trees/MemberTypeTreeController.cs @@ -2,7 +2,10 @@ using System.Linq; using System.Net.Http.Formatting; using Umbraco.Core; +using Umbraco.Core.Models; +using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Models.Trees; +using Umbraco.Web.Search; using Umbraco.Web.WebApi.Filters; namespace Umbraco.Web.Trees @@ -10,8 +13,15 @@ namespace Umbraco.Web.Trees [CoreTree] [UmbracoTreeAuthorize(Constants.Trees.MemberTypes)] [Tree(Constants.Applications.Settings, Constants.Trees.MemberTypes, SortOrder = 2, TreeGroup = Constants.Trees.Groups.Settings)] - public class MemberTypeTreeController : MemberTypeAndGroupTreeControllerBase + public class MemberTypeTreeController : MemberTypeAndGroupTreeControllerBase, ISearchableTree { + private readonly UmbracoTreeSearcher _treeSearcher; + + public MemberTypeTreeController(UmbracoTreeSearcher treeSearcher) + { + _treeSearcher = treeSearcher; + } + protected override TreeNode CreateRootNode(FormDataCollection queryStrings) { var root = base.CreateRootNode(queryStrings); @@ -25,5 +35,9 @@ namespace Umbraco.Web.Trees .OrderBy(x => x.Name) .Select(dt => CreateTreeNode(dt, Constants.ObjectTypes.MemberType, id, queryStrings, Constants.Icons.MemberType, false)); } + + public IEnumerable Search(string query, int pageSize, long pageIndex, out long totalFound, string searchFrom = null) + => _treeSearcher.EntitySearch(UmbracoObjectTypes.MemberType, query, pageSize, pageIndex, out totalFound, searchFrom); + } } diff --git a/src/Umbraco.Web/Trees/RelationTypeTreeController.cs b/src/Umbraco.Web/Trees/RelationTypeTreeController.cs index ab6dd39820..aa3206b5e4 100644 --- a/src/Umbraco.Web/Trees/RelationTypeTreeController.cs +++ b/src/Umbraco.Web/Trees/RelationTypeTreeController.cs @@ -16,6 +16,8 @@ namespace Umbraco.Web.Trees { protected override MenuItemCollection GetMenuForNode(string id, FormDataCollection queryStrings) { + //TODO: Do not allow deleting built in types + var menu = new MenuItemCollection(); if (id == Constants.System.RootString) diff --git a/src/Umbraco.Web/Trees/TemplatesTreeController.cs b/src/Umbraco.Web/Trees/TemplatesTreeController.cs index 8c37718c9b..d0ca8d1b2e 100644 --- a/src/Umbraco.Web/Trees/TemplatesTreeController.cs +++ b/src/Umbraco.Web/Trees/TemplatesTreeController.cs @@ -3,12 +3,18 @@ using System.Globalization; using System.Linq; using System.Net.Http.Formatting; using Umbraco.Core; +using Umbraco.Core.Cache; +using Umbraco.Core.Configuration; +using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; +using Umbraco.Core.Persistence; +using Umbraco.Core.Services; using Umbraco.Web.Actions; using Umbraco.Web.Models.ContentEditing; using Umbraco.Web.Models.Trees; using Umbraco.Web.Mvc; +using Umbraco.Web.Search; using Umbraco.Web.WebApi.Filters; using Constants = Umbraco.Core.Constants; @@ -20,6 +26,13 @@ namespace Umbraco.Web.Trees [CoreTree] public class TemplatesTreeController : TreeController, ISearchableTree { + private readonly UmbracoTreeSearcher _treeSearcher; + + public TemplatesTreeController(UmbracoTreeSearcher treeSearcher, IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) + { + _treeSearcher = treeSearcher; + } + protected override TreeNode CreateRootNode(FormDataCollection queryStrings) { var root = base.CreateRootNode(queryStrings); @@ -119,10 +132,6 @@ namespace Umbraco.Web.Trees } public IEnumerable Search(string query, int pageSize, long pageIndex, out long totalFound, string searchFrom = null) - { - var results = Services.EntityService.GetPagedDescendants(UmbracoObjectTypes.Template, pageIndex, pageSize, out totalFound, - filter: SqlContext.Query().Where(x => x.Name.Contains(query))); - return Mapper.MapEnumerable(results); - } + => _treeSearcher.EntitySearch(UmbracoObjectTypes.Template, query, pageSize, pageIndex, out totalFound, searchFrom); } } diff --git a/src/Umbraco.Web/Trees/TreeControllerBase.cs b/src/Umbraco.Web/Trees/TreeControllerBase.cs index 0f9b61469e..77e5ebef62 100644 --- a/src/Umbraco.Web/Trees/TreeControllerBase.cs +++ b/src/Umbraco.Web/Trees/TreeControllerBase.cs @@ -265,6 +265,7 @@ namespace Umbraco.Web.Trees treeNode.Path = entity.Path; treeNode.Udi = Udi.Create(ObjectTypes.GetUdiType(entityObjectType), entity.Key); treeNode.HasChildren = hasChildren; + treeNode.Trashed = entity.Trashed; return treeNode; } diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 730a321a16..0111a36993 100755 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -13,6 +13,7 @@ true Off + $(AdditionalFileItemNames);Content true @@ -60,9 +61,9 @@ - + - + 2.7.0.100 @@ -88,6 +89,11 @@ + + 3.3.0 + runtime; build; native; contentfiles; analyzers + all + @@ -148,6 +154,7 @@ + @@ -211,6 +218,7 @@ + @@ -221,25 +229,40 @@ + + + + + + + + + + + + + + + @@ -477,8 +500,6 @@ - - @@ -801,7 +822,6 @@ - @@ -839,7 +859,6 @@ - @@ -1201,8 +1220,9 @@ + - + diff --git a/src/Umbraco.Web/UmbracoApplication.cs b/src/Umbraco.Web/UmbracoApplication.cs index 191fb9dcd6..f8ee238da7 100644 --- a/src/Umbraco.Web/UmbracoApplication.cs +++ b/src/Umbraco.Web/UmbracoApplication.cs @@ -1,6 +1,9 @@ -using System.Threading; +using System.Configuration; +using System.Threading; using System.Web; using Umbraco.Core; +using Umbraco.Core.Logging.Serilog; +using Umbraco.Core.Runtime; using Umbraco.Web.Runtime; namespace Umbraco.Web @@ -12,7 +15,18 @@ namespace Umbraco.Web { protected override IRuntime GetRuntime() { - return new WebRuntime(this); + var logger = SerilogLogger.CreateWithDefaultConfiguration(); + + // Determine if we should use the sql main dom or the default + var appSettingMainDomLock = ConfigurationManager.AppSettings[Constants.AppSettings.MainDomLock]; + + var mainDomLock = appSettingMainDomLock == "SqlMainDomLock" + ? (IMainDomLock)new SqlMainDomLock(logger) + : new MainDomSemaphoreLock(logger); + + var runtime = new WebRuntime(this, logger, new MainDom(logger, mainDomLock)); + + return runtime; } /// diff --git a/src/Umbraco.Web/UmbracoComponentRenderer.cs b/src/Umbraco.Web/UmbracoComponentRenderer.cs index f6c3d30da3..0373c73724 100644 --- a/src/Umbraco.Web/UmbracoComponentRenderer.cs +++ b/src/Umbraco.Web/UmbracoComponentRenderer.cs @@ -27,12 +27,14 @@ namespace Umbraco.Web private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly IMacroRenderer _macroRenderer; private readonly ITemplateRenderer _templateRenderer; + private readonly HtmlLocalLinkParser _linkParser; - public UmbracoComponentRenderer(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer, ITemplateRenderer templateRenderer) + public UmbracoComponentRenderer(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer, ITemplateRenderer templateRenderer, HtmlLocalLinkParser linkParser) { _umbracoContextAccessor = umbracoContextAccessor; _macroRenderer = macroRenderer; _templateRenderer = templateRenderer ?? throw new ArgumentNullException(nameof(templateRenderer)); + _linkParser = linkParser; } /// @@ -77,7 +79,7 @@ namespace Umbraco.Web /// public IHtmlString RenderMacro(int contentId, string alias, object parameters) { - return RenderMacro(contentId, alias, parameters.ToDictionary()); + return RenderMacro(contentId, alias, parameters?.ToDictionary()); } /// @@ -92,12 +94,12 @@ namespace Umbraco.Web if (contentId == default) throw new ArgumentException("Invalid content id " + contentId); - var content = _umbracoContextAccessor.UmbracoContext.Content?.GetById(true, contentId); + var content = _umbracoContextAccessor.UmbracoContext.Content?.GetById(contentId); if (content == null) throw new InvalidOperationException("Cannot render a macro, no content found by id " + contentId); - return RenderMacro(alias, parameters, content); + return RenderMacro(content, alias, parameters); } /// @@ -107,61 +109,17 @@ namespace Umbraco.Web /// The parameters. /// The content used for macro rendering /// - private IHtmlString RenderMacro(string alias, IDictionary parameters, IPublishedContent content) + private IHtmlString RenderMacro(IPublishedContent content, string alias, IDictionary parameters) { if (content == null) throw new ArgumentNullException(nameof(content)); // TODO: We are doing at ToLower here because for some insane reason the UpdateMacroModel method looks for a lower case match. the whole macro concept needs to be rewritten. //NOTE: the value could have HTML encoded values, so we need to deal with that - var macroProps = parameters.ToDictionary( + var macroProps = parameters?.ToDictionary( x => x.Key.ToLowerInvariant(), i => (i.Value is string) ? HttpUtility.HtmlDecode(i.Value.ToString()) : i.Value); - var macroControl = _macroRenderer.Render(alias, content, macroProps).GetAsControl(); - - string html; - if (macroControl is LiteralControl control) - { - // no need to execute, we already have text - html = control.Text; - } - else - { - using (var containerPage = new FormlessPage()) - { - containerPage.Controls.Add(macroControl); - - using (var output = new StringWriter()) - { - // .Execute() does a PushTraceContext/PopTraceContext and writes trace output straight into 'output' - // and I do not see how we could wire the trace context to the current context... so it creates dirty - // trace output right in the middle of the page. - // - // The only thing we can do is fully disable trace output while .Execute() runs and restore afterwards - // which means trace output is lost if the macro is a control (.ascx or user control) that is invoked - // from within Razor -- which makes sense anyway because the control can _not_ run correctly from - // within Razor since it will never be inserted into the page pipeline (which may even not exist at all - // if we're running MVC). - // - // I'm sure there's more things that will get lost with this context changing but I guess we'll figure - // those out as we go along. One thing we lose is the content type response output. - // http://issues.umbraco.org/issue/U4-1599 if it is setup during the macro execution. So - // here we'll save the content type response and reset it after execute is called. - - var contentType = _umbracoContextAccessor.UmbracoContext.HttpContext.Response.ContentType; - var traceIsEnabled = containerPage.Trace.IsEnabled; - containerPage.Trace.IsEnabled = false; - _umbracoContextAccessor.UmbracoContext.HttpContext.Server.Execute(containerPage, output, true); - containerPage.Trace.IsEnabled = traceIsEnabled; - //reset the content type - _umbracoContextAccessor.UmbracoContext.HttpContext.Response.ContentType = contentType; - - //Now, we need to ensure that local links are parsed - html = TemplateUtilities.ParseInternalLinks(output.ToString(), _umbracoContextAccessor.UmbracoContext.UrlProvider); - } - } - - } + string html = _macroRenderer.Render(alias, content, macroProps).GetAsText(); return new HtmlString(html); } diff --git a/src/Umbraco.Web/UmbracoContext.cs b/src/Umbraco.Web/UmbracoContext.cs index c0306e0a81..347f79e51b 100644 --- a/src/Umbraco.Web/UmbracoContext.cs +++ b/src/Umbraco.Web/UmbracoContext.cs @@ -1,16 +1,21 @@ using System; using System.Collections.Generic; +using System.IO; using System.Web; +using System.Web.Routing; using Umbraco.Core; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; +using Umbraco.Core.Events; using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web; using Umbraco.Web.PublishedCache; using Umbraco.Web.Routing; using Umbraco.Web.Security; namespace Umbraco.Web { + /// /// Class that encapsulates Umbraco information of a specific HTTP request /// @@ -285,7 +290,7 @@ namespace Umbraco.Web _previewing = _previewToken.IsNullOrWhiteSpace() == false; } - + // say we render a macro or RTE in a give 'preview' mode that might not be the 'current' one, // then due to the way it all works at the moment, the 'current' published snapshot need to be in the proper // default 'preview' mode - somehow we have to force it. and that could be recursive. diff --git a/src/Umbraco.Web/UmbracoHelper.cs b/src/Umbraco.Web/UmbracoHelper.cs index 367d90a504..1bc8df5b24 100644 --- a/src/Umbraco.Web/UmbracoHelper.cs +++ b/src/Umbraco.Web/UmbracoHelper.cs @@ -146,7 +146,7 @@ namespace Umbraco.Web /// public IHtmlString RenderMacro(string alias) { - return ComponentRenderer.RenderMacro(AssignedContentItem?.Id ?? 0, alias, new { }); + return ComponentRenderer.RenderMacro(AssignedContentItem?.Id ?? 0, alias, null); } /// @@ -157,7 +157,7 @@ namespace Umbraco.Web /// public IHtmlString RenderMacro(string alias, object parameters) { - return ComponentRenderer.RenderMacro(AssignedContentItem?.Id ?? 0, alias, parameters.ToDictionary()); + return ComponentRenderer.RenderMacro(AssignedContentItem?.Id ?? 0, alias, parameters?.ToDictionary()); } /// diff --git a/src/Umbraco.Web/UmbracoInjectedModule.cs b/src/Umbraco.Web/UmbracoInjectedModule.cs index c973fafa81..6dc41d39ee 100644 --- a/src/Umbraco.Web/UmbracoInjectedModule.cs +++ b/src/Umbraco.Web/UmbracoInjectedModule.cs @@ -1,8 +1,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.IO; -using System.Text; using System.Web; using System.Web.Routing; using Umbraco.Core; @@ -10,14 +8,9 @@ using Umbraco.Core.Configuration; using Umbraco.Core.IO; using Umbraco.Core.Logging; using Umbraco.Web.Routing; -using Umbraco.Web.Security; using Umbraco.Core.Exceptions; -using Umbraco.Core.Models.PublishedContent; -using Umbraco.Core.Persistence.FaultHandling; using Umbraco.Core.Security; -using Umbraco.Core.Services; using Umbraco.Web.Composing; -using Umbraco.Web.PublishedCache; namespace Umbraco.Web { @@ -39,40 +32,26 @@ namespace Umbraco.Web public class UmbracoInjectedModule : IHttpModule { private readonly IGlobalSettings _globalSettings; - private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly IPublishedSnapshotService _publishedSnapshotService; - private readonly IUserService _userService; - private readonly UrlProviderCollection _urlProviders; private readonly IRuntimeState _runtime; private readonly ILogger _logger; private readonly IPublishedRouter _publishedRouter; - private readonly IVariationContextAccessor _variationContextAccessor; private readonly IUmbracoContextFactory _umbracoContextFactory; + private readonly RoutableDocumentFilter _routableDocumentLookup; public UmbracoInjectedModule( IGlobalSettings globalSettings, - IUmbracoContextAccessor umbracoContextAccessor, - IPublishedSnapshotService publishedSnapshotService, - IUserService userService, - UrlProviderCollection urlProviders, IRuntimeState runtime, ILogger logger, IPublishedRouter publishedRouter, - IVariationContextAccessor variationContextAccessor, - IUmbracoContextFactory umbracoContextFactory) + IUmbracoContextFactory umbracoContextFactory, + RoutableDocumentFilter routableDocumentLookup) { - _combinedRouteCollection = new Lazy(CreateRouteCollection); - _globalSettings = globalSettings; - _umbracoContextAccessor = umbracoContextAccessor; - _publishedSnapshotService = publishedSnapshotService; - _userService = userService; - _urlProviders = urlProviders; _runtime = runtime; _logger = logger; _publishedRouter = publishedRouter; - _variationContextAccessor = variationContextAccessor; _umbracoContextFactory = umbracoContextFactory; + _routableDocumentLookup = routableDocumentLookup; } #region HttpModule event handlers @@ -182,18 +161,18 @@ namespace Umbraco.Web var reason = EnsureRoutableOutcome.IsRoutable; // ensure this is a document request - if (EnsureDocumentRequest(httpContext, uri) == false) + if (!_routableDocumentLookup.IsDocumentRequest(httpContext, context.OriginalRequestUrl)) { reason = EnsureRoutableOutcome.NotDocumentRequest; } // ensure the runtime is in the proper state // and deal with needed redirects, etc - else if (EnsureRuntime(httpContext, uri) == false) + else if (!EnsureRuntime(httpContext, uri)) { reason = EnsureRoutableOutcome.NotReady; } // ensure Umbraco has documents to serve - else if (EnsureHasContent(context, httpContext) == false) + else if (!EnsureHasContent(context, httpContext)) { reason = EnsureRoutableOutcome.NoContent; } @@ -201,55 +180,7 @@ namespace Umbraco.Web return Attempt.If(reason == EnsureRoutableOutcome.IsRoutable, reason); } - /// - /// Ensures that the request is a document request (i.e. one that the module should handle) - /// - /// - /// - /// - private bool EnsureDocumentRequest(HttpContextBase httpContext, Uri uri) - { - var maybeDoc = true; - var lpath = uri.AbsolutePath.ToLowerInvariant(); - - // handle directory-urls used for asmx - // TODO: legacy - what's the point really? - var asmxPos = lpath.IndexOf(".asmx/", StringComparison.OrdinalIgnoreCase); - if (asmxPos >= 0) - { - // use uri.AbsolutePath, not path, 'cos path has been lowercased - httpContext.RewritePath(uri.AbsolutePath.Substring(0, asmxPos + 5), // filePath - uri.AbsolutePath.Substring(asmxPos + 5), // pathInfo - uri.Query.TrimStart('?')); - maybeDoc = false; - } - - // a document request should be - // /foo/bar/nil - // /foo/bar/nil/ - // /foo/bar/nil.aspx - // where /foo is not a reserved path - - // if the path contains an extension that is not .aspx - // then it cannot be a document request - var extension = Path.GetExtension(lpath); - if (maybeDoc && extension.IsNullOrWhiteSpace() == false && extension != ".aspx") - maybeDoc = false; - - // at that point, either we have no extension, or it is .aspx - - // if the path is reserved then it cannot be a document request - if (maybeDoc && _globalSettings.IsReservedPathOrUrl(lpath, httpContext, _combinedRouteCollection.Value)) - maybeDoc = false; - - //NOTE: No need to warn, plus if we do we should log the document, as this message doesn't really tell us anything :) - //if (!maybeDoc) - //{ - // Logger.Warn("Not a document"); - //} - - return maybeDoc; - } + private bool EnsureRuntime(HttpContextBase httpContext, Uri uri) { @@ -264,7 +195,7 @@ namespace Umbraco.Web case RuntimeLevel.Unknown: case RuntimeLevel.Boot: case RuntimeLevel.BootFailed: - throw new Exception($"panic: Unexpected runtime level: {level}."); + throw new PanicException($"Unexpected runtime level: {level}."); case RuntimeLevel.Run: // ok @@ -505,36 +436,6 @@ namespace Umbraco.Web #endregion - /// - /// This is used to be passed into the GlobalSettings.IsReservedPathOrUrl and will include some 'fake' routes - /// used to determine if a path is reserved. - /// - /// - /// This is basically used to reserve paths dynamically - /// - private readonly Lazy _combinedRouteCollection; - - private RouteCollection CreateRouteCollection() - { - var routes = new RouteCollection(); - - foreach (var route in RouteTable.Routes) - routes.Add(route); - - foreach (var reservedPath in UmbracoModule.ReservedPaths) - { - try - { - routes.Add("_umbreserved_" + reservedPath.ReplaceNonAlphanumericChars(""), - new Route(reservedPath.TrimStart('/'), new StopRoutingHandler())); - } - catch (Exception ex) - { - _logger.Error("Could not add reserved path route", ex); - } - } - - return routes; - } + } } diff --git a/src/Umbraco.Web/UmbracoModule.cs b/src/Umbraco.Web/UmbracoModule.cs index 048e521969..2ec4141f5a 100644 --- a/src/Umbraco.Web/UmbracoModule.cs +++ b/src/Umbraco.Web/UmbracoModule.cs @@ -1,7 +1,6 @@ using System; using System.Web; using Umbraco.Core; -using Umbraco.Core.Collections; using Umbraco.Core.Logging; using Umbraco.Web.Composing; using Umbraco.Web.Routing; @@ -102,11 +101,5 @@ namespace Umbraco.Web return end; } - - /// - /// This is used internally to track any registered callback paths for Identity providers. If the request path matches - /// any of the registered paths, then the module will let the request keep executing - /// - internal static readonly ConcurrentHashSet ReservedPaths = new ConcurrentHashSet(); } } diff --git a/src/Umbraco.Web/UrlHelperExtensions.cs b/src/Umbraco.Web/UrlHelperExtensions.cs index 249ce76193..7e63ff16b8 100644 --- a/src/Umbraco.Web/UrlHelperExtensions.cs +++ b/src/Umbraco.Web/UrlHelperExtensions.cs @@ -2,15 +2,12 @@ using System.Globalization; using System.Linq; using System.Linq.Expressions; -using System.Management.Instrumentation; using System.Web.Mvc; using System.Web.Routing; using ClientDependency.Core.Config; using Umbraco.Core; using Umbraco.Core.Configuration; -using Umbraco.Core.Exceptions; using Umbraco.Web.Composing; -using Umbraco.Web.Editors; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; @@ -70,7 +67,8 @@ namespace Umbraco.Web /// public static string GetUmbracoApiService(this UrlHelper url, string actionName, Type apiControllerType, RouteValueDictionary routeVals = null) { - if (string.IsNullOrEmpty(actionName)) throw new ArgumentNullOrEmptyException(nameof(actionName)); + if (actionName == null) throw new ArgumentNullException(nameof(actionName)); + if (string.IsNullOrEmpty(actionName)) throw new ArgumentException("Value can't be empty.", nameof(actionName)); if (apiControllerType == null) throw new ArgumentNullException(nameof(apiControllerType)); var area = ""; @@ -99,8 +97,10 @@ namespace Umbraco.Web /// public static string GetUmbracoApiService(this UrlHelper url, string actionName, string controllerName, string area, RouteValueDictionary routeVals = null) { - if (string.IsNullOrEmpty(controllerName)) throw new ArgumentNullOrEmptyException(nameof(controllerName)); - if (string.IsNullOrEmpty(actionName)) throw new ArgumentNullOrEmptyException(nameof(actionName)); + if (actionName == null) throw new ArgumentNullException(nameof(actionName)); + if (string.IsNullOrEmpty(actionName)) throw new ArgumentException("Value can't be empty.", nameof(actionName)); + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + if (string.IsNullOrEmpty(controllerName)) throw new ArgumentException("Value can't be empty.", nameof(controllerName)); if (routeVals == null) { diff --git a/src/Umbraco.Web/UrlHelperRenderExtensions.cs b/src/Umbraco.Web/UrlHelperRenderExtensions.cs index 48c26c93dd..8e410bf584 100644 --- a/src/Umbraco.Web/UrlHelperRenderExtensions.cs +++ b/src/Umbraco.Web/UrlHelperRenderExtensions.cs @@ -4,8 +4,6 @@ using System.Linq; using System.Web; using System.Web.Mvc; using Umbraco.Core; -using Umbraco.Core.Exceptions; -using Umbraco.Core.Models; using Umbraco.Core.Models.PublishedContent; using Umbraco.Core.PropertyEditors.ValueConverters; using Umbraco.Web.Composing; @@ -302,8 +300,10 @@ namespace Umbraco.Web /// public static string SurfaceAction(this UrlHelper url, string action, string controllerName, string area, object additionalRouteVals) { - if (string.IsNullOrEmpty(action)) throw new ArgumentNullOrEmptyException(nameof(action)); - if (string.IsNullOrEmpty(controllerName)) throw new ArgumentNullOrEmptyException(nameof(controllerName)); + if (action == null) throw new ArgumentNullException(nameof(action)); + if (string.IsNullOrEmpty(action)) throw new ArgumentException("Value can't be empty.", nameof(action)); + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + if (string.IsNullOrEmpty(controllerName)) throw new ArgumentException("Value can't be empty.", nameof(controllerName)); var encryptedRoute = CreateEncryptedRouteString(controllerName, action, area, additionalRouteVals); @@ -333,7 +333,8 @@ namespace Umbraco.Web /// public static string SurfaceAction(this UrlHelper url, string action, Type surfaceType, object additionalRouteVals) { - if (string.IsNullOrEmpty(action)) throw new ArgumentNullOrEmptyException(nameof(action)); + if (action == null) throw new ArgumentNullException(nameof(action)); + if (string.IsNullOrEmpty(action)) throw new ArgumentException("Value can't be empty.", nameof(action)); if (surfaceType == null) throw new ArgumentNullException(nameof(surfaceType)); var area = ""; @@ -392,8 +393,10 @@ namespace Umbraco.Web /// internal static string CreateEncryptedRouteString(string controllerName, string controllerAction, string area, object additionalRouteVals = null) { - if (string.IsNullOrEmpty(controllerName)) throw new ArgumentNullOrEmptyException(nameof(controllerName)); - if (string.IsNullOrEmpty(controllerAction)) throw new ArgumentNullOrEmptyException(nameof(controllerAction)); + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + if (string.IsNullOrEmpty(controllerName)) throw new ArgumentException("Value can't be empty.", nameof(controllerName)); + if (controllerAction == null) throw new ArgumentNullException(nameof(controllerAction)); + if (string.IsNullOrEmpty(controllerAction)) throw new ArgumentException("Value can't be empty.", nameof(controllerAction)); if (area == null) throw new ArgumentNullException(nameof(area)); //need to create a params string as Base64 to put into our hidden field to use during the routes diff --git a/src/Umbraco.Web/WebApi/Filters/EnsureUserPermissionForContentAttribute.cs b/src/Umbraco.Web/WebApi/Filters/EnsureUserPermissionForContentAttribute.cs index efee045890..28f09b46b7 100644 --- a/src/Umbraco.Web/WebApi/Filters/EnsureUserPermissionForContentAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/EnsureUserPermissionForContentAttribute.cs @@ -1,16 +1,13 @@ using System; +using System.Net; using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.Filters; -using Umbraco.Core.Exceptions; -using Umbraco.Web.Composing; -using Umbraco.Web.Editors; - using Umbraco.Core; using Umbraco.Core.Models; -using Umbraco.Web.Actions; using Umbraco.Core.Security; -using System.Net; +using Umbraco.Web.Actions; +using Umbraco.Web.Composing; namespace Umbraco.Web.WebApi.Filters { @@ -46,7 +43,9 @@ namespace Umbraco.Web.WebApi.Filters public EnsureUserPermissionForContentAttribute(string paramName) { - if (string.IsNullOrEmpty(paramName)) throw new ArgumentNullOrEmptyException(nameof(paramName)); + if (paramName == null) throw new ArgumentNullException(nameof(paramName)); + if (string.IsNullOrEmpty(paramName)) throw new ArgumentException("Value can't be empty.", nameof(paramName)); + _paramName = paramName; _permissionToCheck = ActionBrowse.ActionLetter; } diff --git a/src/Umbraco.Web/WebApi/Filters/EnsureUserPermissionForMediaAttribute.cs b/src/Umbraco.Web/WebApi/Filters/EnsureUserPermissionForMediaAttribute.cs index 24bf2ea9a8..60e2889fd5 100644 --- a/src/Umbraco.Web/WebApi/Filters/EnsureUserPermissionForMediaAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/EnsureUserPermissionForMediaAttribute.cs @@ -3,7 +3,6 @@ using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.Filters; using Umbraco.Core; -using Umbraco.Core.Exceptions; using Umbraco.Core.Models; using Umbraco.Web.Composing; using Umbraco.Web.Editors; @@ -38,14 +37,18 @@ namespace Umbraco.Web.WebApi.Filters public EnsureUserPermissionForMediaAttribute(string paramName) { - if (string.IsNullOrEmpty(paramName)) throw new ArgumentNullOrEmptyException(nameof(paramName)); + if (paramName == null) throw new ArgumentNullException(nameof(paramName)); + if (string.IsNullOrEmpty(paramName)) throw new ArgumentException("Value can't be empty.", nameof(paramName)); + _paramName = paramName; } // TODO: v8 guess this is not used anymore, source is ignored?! public EnsureUserPermissionForMediaAttribute(string paramName, DictionarySource source) { - if (string.IsNullOrEmpty(paramName)) throw new ArgumentNullOrEmptyException(nameof(paramName)); + if (paramName == null) throw new ArgumentNullException(nameof(paramName)); + if (string.IsNullOrEmpty(paramName)) throw new ArgumentException("Value can't be empty.", nameof(paramName)); + _paramName = paramName; } diff --git a/src/Umbraco.Web/WebApi/Filters/FileUploadCleanupFilterAttribute.cs b/src/Umbraco.Web/WebApi/Filters/FileUploadCleanupFilterAttribute.cs index 6e6ec35b1b..1646c8a426 100644 --- a/src/Umbraco.Web/WebApi/Filters/FileUploadCleanupFilterAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/FileUploadCleanupFilterAttribute.cs @@ -146,28 +146,6 @@ namespace Umbraco.Web.WebApi.Filters Current.Logger.Warn("The actionExecutedContext.Request.Content is not ObjectContent, it is {RequestObjectType}", actionExecutedContext.Request.Content.GetType()); } } - - //Now remove all old files so that the temp folder(s) never grow - foreach (var tempFolder in tempFolders.Distinct()) - { - var files = Directory.GetFiles(tempFolder); - foreach (var file in files) - { - if (DateTime.UtcNow - File.GetLastWriteTimeUtc(file) > TimeSpan.FromDays(1)) - { - try - { - File.Delete(file); - } - catch (System.Exception ex) - { - Current.Logger.Error(ex, "Could not delete temp file {FileName}", file); - } - } - } - - } - } } } diff --git a/src/Umbraco.Web/WebApi/Filters/OutgoingDateTimeFormatAttribute.cs b/src/Umbraco.Web/WebApi/Filters/OutgoingDateTimeFormatAttribute.cs index 713bce7d1b..7f05d59a18 100644 --- a/src/Umbraco.Web/WebApi/Filters/OutgoingDateTimeFormatAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/OutgoingDateTimeFormatAttribute.cs @@ -1,10 +1,8 @@ -using Newtonsoft.Json.Converters; -using System; +using System; using System.Linq; using System.Net.Http.Formatting; using System.Web.Http.Controllers; -using Umbraco.Core; -using Umbraco.Core.Exceptions; +using Newtonsoft.Json.Converters; namespace Umbraco.Web.WebApi.Filters { @@ -21,7 +19,9 @@ namespace Umbraco.Web.WebApi.Filters /// public JsonDateTimeFormatAttributeAttribute(string format) { - if (string.IsNullOrEmpty(format)) throw new ArgumentNullOrEmptyException(nameof(format)); + if (format == null) throw new ArgumentNullException(nameof(format)); + if (string.IsNullOrEmpty(format)) throw new ArgumentException("Value can't be empty.", nameof(format)); + _format = format; } diff --git a/src/Umbraco.Web/WebApi/Filters/ValidateAngularAntiForgeryTokenAttribute.cs b/src/Umbraco.Web/WebApi/Filters/ValidateAngularAntiForgeryTokenAttribute.cs index 0abdfb5d2f..f147a2a4cb 100644 --- a/src/Umbraco.Web/WebApi/Filters/ValidateAngularAntiForgeryTokenAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/ValidateAngularAntiForgeryTokenAttribute.cs @@ -21,7 +21,7 @@ namespace Umbraco.Web.WebApi.Filters var userIdentity = ((ApiController) actionContext.ControllerContext.Controller).User.Identity as ClaimsIdentity; if (userIdentity != null) { - //if there is not CookiePath claim, then exist + //if there is not CookiePath claim, then exit if (userIdentity.HasClaim(x => x.Type == ClaimTypes.CookiePath) == false) { base.OnActionExecuting(actionContext); diff --git a/src/Umbraco.Web/WebApi/UmbracoApiController.cs b/src/Umbraco.Web/WebApi/UmbracoApiController.cs index 8a2e54ca02..4600c02ac1 100644 --- a/src/Umbraco.Web/WebApi/UmbracoApiController.cs +++ b/src/Umbraco.Web/WebApi/UmbracoApiController.cs @@ -1,8 +1,10 @@ -using Umbraco.Core; +using System; +using Umbraco.Core; using Umbraco.Core.Cache; using Umbraco.Core.Composing; using Umbraco.Core.Configuration; using Umbraco.Core.Logging; +using Umbraco.Core.Mapping; using Umbraco.Core.Persistence; using Umbraco.Core.Services; @@ -17,9 +19,15 @@ namespace Umbraco.Web.WebApi { } + [Obsolete("This constructor is obsolete since it doesn't inject the UmbracoMapper. The UmbracoMapper will be resolved from the service locator Current.Mapper, which is not good for testability. Inject the UmbracoMapper using full constructor injection instead.")] protected UmbracoApiController(IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper) { } + + protected UmbracoApiController(IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper, UmbracoMapper umbracoMapper) + : base(globalSettings, umbracoContextAccessor, sqlContext, services, appCaches, logger, runtimeState, umbracoHelper, umbracoMapper) + { + } } } diff --git a/src/Umbraco.Web/WebApi/UmbracoApiControllerBase.cs b/src/Umbraco.Web/WebApi/UmbracoApiControllerBase.cs index 89a630fb5d..a1cd7708e2 100644 --- a/src/Umbraco.Web/WebApi/UmbracoApiControllerBase.cs +++ b/src/Umbraco.Web/WebApi/UmbracoApiControllerBase.cs @@ -40,13 +40,28 @@ namespace Umbraco.Web.WebApi Current.Factory.GetInstance(), Current.Factory.GetInstance(), Current.Factory.GetInstance(), - Current.Factory.GetInstance() + Current.Factory.GetInstance(), + Current.Factory.GetInstance() ) { } /// /// Initializes a new instance of the class with all its dependencies. /// + protected UmbracoApiControllerBase(IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper, UmbracoMapper umbracoMapper) + { + UmbracoContextAccessor = umbracoContextAccessor; + GlobalSettings = globalSettings; + SqlContext = sqlContext; + Services = services; + AppCaches = appCaches; + Logger = logger; + RuntimeState = runtimeState; + Umbraco = umbracoHelper; + Mapper = umbracoMapper; + } + + [Obsolete("This constructor is obsolete since it doesn't inject the UmbracoMapper. The UmbracoMapper will be resolved from the service locator Current.Mapper, which is not good for testability. Inject the UmbracoMapper using full constructor injection instead.")] protected UmbracoApiControllerBase(IGlobalSettings globalSettings, IUmbracoContextAccessor umbracoContextAccessor, ISqlContext sqlContext, ServiceContext services, AppCaches appCaches, IProfilingLogger logger, IRuntimeState runtimeState, UmbracoHelper umbracoHelper) { UmbracoContextAccessor = umbracoContextAccessor; @@ -58,7 +73,7 @@ namespace Umbraco.Web.WebApi RuntimeState = runtimeState; Umbraco = umbracoHelper; - // fixme - can we break all ctors? + // not good for testability, hence the obsolete. Mapper = Current.Mapper; } diff --git a/src/umbraco.sln b/src/umbraco.sln index 39b757f88c..a747f21d19 100644 --- a/src/umbraco.sln +++ b/src/umbraco.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27004.2005 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29209.152 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.Web.UI", "Umbraco.Web.UI\Umbraco.Web.UI.csproj", "{4C4C194C-B5E4-4991-8F87-4373E24CC19F}" EndProject @@ -102,6 +102,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IssueTemplates", "IssueTemp ..\.github\ISSUE_TEMPLATE\5_Security_issue.md = ..\.github\ISSUE_TEMPLATE\5_Security_issue.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.ModelsBuilder.Embedded", "Umbraco.ModelsBuilder.Embedded\Umbraco.ModelsBuilder.Embedded.csproj", "{52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.TestData", "Umbraco.TestData\Umbraco.TestData.csproj", "{FB5676ED-7A69-492C-B802-E7B24144C0FC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -134,6 +138,14 @@ Global {3A33ADC9-C6C0-4DB1-A613-A9AF0210DF3D}.Debug|Any CPU.Build.0 = Debug|Any CPU {3A33ADC9-C6C0-4DB1-A613-A9AF0210DF3D}.Release|Any CPU.ActiveCfg = Release|Any CPU {3A33ADC9-C6C0-4DB1-A613-A9AF0210DF3D}.Release|Any CPU.Build.0 = Release|Any CPU + {52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}.Release|Any CPU.Build.0 = Release|Any CPU + {FB5676ED-7A69-492C-B802-E7B24144C0FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB5676ED-7A69-492C-B802-E7B24144C0FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB5676ED-7A69-492C-B802-E7B24144C0FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB5676ED-7A69-492C-B802-E7B24144C0FC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -146,6 +158,7 @@ Global {53594E5B-64A2-4545-8367-E3627D266AE8} = {FD962632-184C-4005-A5F3-E705D92FC645} {3A33ADC9-C6C0-4DB1-A613-A9AF0210DF3D} = {B5BD12C1-A454-435E-8A46-FF4A364C0382} {C7311C00-2184-409B-B506-52A5FAEA8736} = {FD962632-184C-4005-A5F3-E705D92FC645} + {FB5676ED-7A69-492C-B802-E7B24144C0FC} = {B5BD12C1-A454-435E-8A46-FF4A364C0382} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7A0F2E34-D2AF-4DAB-86A0-7D7764B3D0EC}