This event was so much fun, I couldn’t wait to share a few of my favorite shots!"
+ },
+ {
+ "op": "del",
+ "value": " ..."
+ },
+ {
+ "op": "copy",
+ "value": "
This event was so much fun, I couldn’t wait to share a few of my favorite shots!"
+ },
+ {
+ "op": "add",
+ "value": " ..."
+ },
+ {
+ "op": "copy",
+ "value": "
It's"
+ },
+ {
+ "op": "add",
+ "value": "image"
+ },
+ {
+ "op": "copy",
+ "value": " "
+ },
+ {
+ "op": "del",
+ "value": "a great season for outdoor family portrait sessions and now is the time to book them!"
+ },
+ {
+ "op": "del",
+ "value": "
\n\n\n\n
We offer a number of family portrait packages"
+ },
+ {
+ "op": "add",
+ "value": ":209}"
+ },
+ {
+ "op": "del",
+ "value": " and for a limited time are offering 15% off packages booked before May 1.
It's"
+ },
+ {
+ "op": "copy",
+ "value": " "
+ },
+ {
+ "op": "add",
+ "value": "a great season for outdoor family portrait sessions and now is the time to book them!"
+ },
+ {
+ "op": "del",
+ "value": "{"
+ },
+ {
+ "op": "add",
+ "value": "
\n\n\n\n
We offer a number of family portrait packages"
+ },
+ {
+ "op": "add",
+ "value": " and for a limited time are offering 15% off packages booked before May 1.
"
+ },
+ {
+ "op": "copy",
+ "value": "This event was so much fun, I couldn’t wait to share a few of my favorite shots!"
+ },
+ {
+ "op": "add",
+ "value": "
This event was so much fun, I couldn’t wait to share a few of my favorite shots!
\n\n\n\n\n",
+ "post_excerpt": "",
+ "post_title": "Summer Band Jam"
+ },
+ "240": {
+ "post_date_gmt": "2019-03-20 23:45:49Z",
+ "post_modified_gmt": "2019-03-20 23:45:49Z",
+ "post_author": "14151046",
+ "id": 240,
+ "post_content": "This event was so much fun, I couldn’t wait to share a few of my favorite shots!\n\n",
+ "post_excerpt": "",
+ "post_title": "Summer Band Jam"
+ },
+ "214": {
+ "post_date_gmt": "2019-02-15 23:26:48Z",
+ "post_modified_gmt": "2019-02-15 23:26:48Z",
+ "post_author": "68646169",
+ "id": 214,
+ "post_content": "This event was so much fun, I couldn’t wait to share a few of my favorite shots!",
+ "post_excerpt": "",
+ "post_title": "Summer Band Jam"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_215_diffs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_215_diffs.json
new file mode 100644
index 000000000000..d6f530c3fee7
--- /dev/null
+++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_215_diffs.json
@@ -0,0 +1,71 @@
+{
+ "request": {
+ "method": "GET",
+ "urlPath": "/rest/v1.1/sites/106707880/post/215/diffs/",
+ "queryParameters": {
+ "locale": {
+ "matches": "(.*)"
+ }
+ }
+ },
+ "response": {
+ "status": 200,
+ "jsonBody": {
+ "diffs": [
+ {
+ "from": 0,
+ "to": 216,
+ "diff": {
+ "post_title": [
+ {
+ "op": "del",
+ "value": ""
+ },
+ {
+ "op": "add",
+ "value": "Ideas"
+ },
+ {
+ "op": "copy",
+ "value": "\n"
+ }
+ ],
+ "post_content": [
+ {
+ "op": "add",
+ "value": "Returning client special - Offer a discount to clients who have left a review."
+ },
+ {
+ "op": "copy",
+ "value": "\n\n"
+ },
+ {
+ "op": "add",
+ "value": "Photography classes at the local"
+ },
+ {
+ "op": "copy",
+ "value": "\n"
+ }
+ ],
+ "totals": {
+ "del": 0,
+ "add": 20
+ }
+ }
+ }
+ ],
+ "revisions": {
+ "216": {
+ "post_date_gmt": "2019-02-15 23:27:13Z",
+ "post_modified_gmt": "2019-02-15 23:27:13Z",
+ "post_author": "68646169",
+ "id": 216,
+ "post_content": "Returning client special - Offer a discount to clients who have left a review.\n\nPhotography classes at the local",
+ "post_excerpt": "",
+ "post_title": "Ideas"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_387_diffs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_387_diffs.json
new file mode 100644
index 000000000000..4e1dbb8b8a10
--- /dev/null
+++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_387_diffs.json
@@ -0,0 +1,71 @@
+{
+ "request": {
+ "method": "GET",
+ "urlPath": "/rest/v1.1/sites/106707880/post/387/diffs/",
+ "queryParameters": {
+ "locale": {
+ "matches": "(.*)"
+ }
+ }
+ },
+ "response": {
+ "status": 200,
+ "jsonBody": {
+ "diffs": [
+ {
+ "from": 0,
+ "to": 390,
+ "diff": {
+ "post_title": [
+ {
+ "op": "del",
+ "value": ""
+ },
+ {
+ "op": "add",
+ "value": "Time to Book Summer Sessions"
+ },
+ {
+ "op": "copy",
+ "value": "\n"
+ }
+ ],
+ "post_content": [
+ {
+ "op": "add",
+ "value": "Blue skies and warm weather, what's not to love about summer?"
+ },
+ {
+ "op": "copy",
+ "value": "\n\n"
+ },
+ {
+ "op": "add",
+ "value": "It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before May 1.\n\n\n\nHow to book\nEmail us to set up a time to visit our studio."
+ },
+ {
+ "op": "copy",
+ "value": "\n"
+ }
+ ],
+ "totals": {
+ "del": 0,
+ "add": 86
+ }
+ }
+ }
+ ],
+ "revisions": {
+ "390": {
+ "post_date_gmt": "2019-05-28 21:03:03Z",
+ "post_modified_gmt": "2019-05-28 21:03:03Z",
+ "post_author": "742098",
+ "id": 390,
+ "post_content": "Blue skies and warm weather, what's not to love about summer?\n\nIt's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before May 1.\n\n\n\nHow to book\nEmail us to set up a time to visit our studio.",
+ "post_excerpt": "",
+ "post_title": "Time to Book Summer Sessions"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_396_diffs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_396_diffs.json
new file mode 100644
index 000000000000..5634833bff6f
--- /dev/null
+++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_396_diffs.json
@@ -0,0 +1,130 @@
+{
+ "request": {
+ "method": "GET",
+ "urlPath": "/rest/v1.1/sites/106707880/post/396/diffs/",
+ "queryParameters": {
+ "locale": {
+ "matches": "(.*)"
+ }
+ }
+ },
+ "response": {
+ "status": 200,
+ "jsonBody": {
+ "diffs": [
+ {
+ "from": 398,
+ "to": 399,
+ "diff": {
+ "post_title": [
+ {
+ "op": "copy",
+ "value": "Now Booking Summer Sessions"
+ }
+ ],
+ "post_content": [
+ {
+ "op": "copy",
+ "value": "
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\n"
+ },
+ {
+ "op": "add",
+ "value": "\n"
+ },
+ {
+ "op": "copy",
+ "value": "\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book"
+ },
+ {
+ "op": "del",
+ "value": "\n<"
+ },
+ {
+ "op": "add",
+ "value": "<"
+ },
+ {
+ "op": "copy",
+ "value": "/strong>"
+ },
+ {
+ "op": "add",
+ "value": "\n\n\n"
+ },
+ {
+ "op": "copy",
+ "value": "Email us to set up a time to visit our studio.\n"
+ }
+ ],
+ "totals": {
+ "add": 0,
+ "del": 0
+ }
+ }
+ },
+ {
+ "from": 0,
+ "to": 398,
+ "diff": {
+ "post_title": [
+ {
+ "op": "del",
+ "value": ""
+ },
+ {
+ "op": "add",
+ "value": "Now Booking Summer Sessions"
+ },
+ {
+ "op": "copy",
+ "value": "\n"
+ }
+ ],
+ "post_content": [
+ {
+ "op": "add",
+ "value": "
“One must maintain a little bit of summer, even in the middle of winter.”"
+ },
+ {
+ "op": "copy",
+ "value": "\n\n"
+ },
+ {
+ "op": "add",
+ "value": "– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book\nEmail us to set up a time to visit our studio."
+ },
+ {
+ "op": "copy",
+ "value": "\n"
+ }
+ ],
+ "totals": {
+ "del": 0,
+ "add": 101
+ }
+ }
+ }
+ ],
+ "revisions": {
+ "399": {
+ "post_date_gmt": "2019-05-28 21:06:50Z",
+ "post_modified_gmt": "2019-05-28 21:06:50Z",
+ "post_author": "742098",
+ "id": 399,
+ "post_content": "
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\n\n\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book\n\n\nEmail us to set up a time to visit our studio.",
+ "post_excerpt": "",
+ "post_title": "Now Booking Summer Sessions"
+ },
+ "398": {
+ "post_date_gmt": "2019-05-28 21:05:17Z",
+ "post_modified_gmt": "2019-05-28 21:05:17Z",
+ "post_author": "742098",
+ "id": 398,
+ "post_content": "
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n
“One must maintain a little bit of summer, even in the middle of winter.
– Henry David Thoreau
\n\n\n\n
Blue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!
\n\n\n\n
We offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n
“One must maintain a little bit of summer, even in the middle of winter.”
– Henry David Thoreau
\n\n\n\n
Blue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!
\n\n\n\n
We offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.
The most impossible sounding thing about the Impossible Burger isn’t the idea of a delicious meatless protein — some of us have been eating delicious meat substitutes for years — it’s hearing people talk about this food product in terms of scalability, optics, engineering, and “manufacturable prototypes.” The future has arrived, and it tastes better than it sounds. Chris Ip writes for Engadget about the brief history, challenges, and ambitions of Impossible Foods’ meat-free technology, and how its success relates to a planet that’s warming partly because of industrial beef production.
\n
“Ethical consumerism is a failure and doesn’t really accomplish what we want it to accomplish,” said Michael Selden, CEO and founder of Finless Foods, a cell-based seafood startup. “What you need to do is create things that are ethical and moral as a baseline but make them compete on metrics of taste, price and convenience, which is what people actually buy food on, and Impossible has really embodied that.”
\n
There’s a comparison to sustainable energy here: We all need it and we’re barely willing to curtail our electricity demands, but if there’s a price-competitive, clean alternative, then sure. With food, it’s an acknowledgement that — solely for the guaranteed sensory enjoyment that those who are food secure might enjoy each day — taste is the key driver to change our habits.
\n
This leaves Impossible in a nice position. The global economic demand for meat combined with the swelling cultural-political urgency to curtail it could be great for business if you have a legitimate alternative. And the high-risk-high-reward venture capital system demands startups that can pitch themselves as limitlessly scalable. A worldwide problem of this degree means that Impossible can plausibly — and not disingenuously — bridge both a business goal of sky’s-the-limit growth and a messianic narrative. Brown’s social mission aligns with his profit-seeking obligation in ways that can make as much sense to private equity as to Katy Perry.
Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Proin dictum non ligula aliquam varius. Nam ornare accumsan ante, sollicitudin bibendum erat bibendum nec. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis.
\n",
+ "excerpt": "
Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Proin dictum non ligula aliquam varius. Nam ornare accumsan ante, sollicitudin bibendum erat bibendum nec. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis.
“Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.” At Man Repeller, Molly Conway muses on imposter syndrome, work and identity, and being a playwright.
\n",
+ "excerpt": "
“Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.” At Man Repeller, Molly Conway muses on imposter syndrome, work and identity, and being a playwright.
The team at Barista Hustle, a leading coffee-education hub, creates resources and shares their extensive knowledge on topics ranging from cutting-edge gear to latte art.
\n",
+ "excerpt": "
The team at Barista Hustle, a leading coffee-education hub, creates resources and shares their extensive knowledge on topics ranging from cutting-edge gear to latte art.
Lonely Planet Kids — an offshoot of the popular travel guide company –inspires children to be curious about the world. The site features books, activities, family travel posts, and more.
\n",
+ "excerpt": "
Lonely Planet Kids — an offshoot of the popular travel guide company –inspires children to be curious about the world. The site features books, activities, family travel posts, and more.
“Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.” At Man Repeller, Molly Conway muses on imposter syndrome, work and identity, and being a playwright.
\n",
+ "excerpt": "
“Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.” At Man Repeller, Molly Conway muses on imposter syndrome, work and identity, and being a playwright.
The team at Barista Hustle, a leading coffee-education hub, creates resources and shares their extensive knowledge on topics ranging from cutting-edge gear to latte art.
\n",
+ "excerpt": "
The team at Barista Hustle, a leading coffee-education hub, creates resources and shares their extensive knowledge on topics ranging from cutting-edge gear to latte art.
Lonely Planet Kids — an offshoot of the popular travel guide company –inspires children to be curious about the world. The site features books, activities, family travel posts, and more.
\n",
+ "excerpt": "
Lonely Planet Kids — an offshoot of the popular travel guide company –inspires children to be curious about the world. The site features books, activities, family travel posts, and more.
My return to blogging was fun and satisfying after a tumultuous break! Thank you for welcoming me back last week. It was great to catch up with you!
\n\n\n\n
If you are confused by this week’s Sunday Stills post, we are examining the world of water droplets. In my dry, still excessively warm part of the world, if I want water, I must turn on the garden hose. Artificially produced water droplets will work!
\n\n\n\n\n\n\n\n
After the few weeks of turmoil I recently experienced, I find calm and peace in my backyard garden any time of year.
\n\n\n\n
During our recent visit to Spokane, a few raindrops made their presence known by the end of the week.
\n\n\n\n\n\n\n\n
After a long, dry spell, my current library of water droplets is depleted, so please enjoy a few of my favorites from the past:
\n\n\n\n\n\n\n\n
My plumeria blossomed last year but no blooms this year. I’m pretty sure I blew up social media and my blog with images of plumeria last summer. Here are a couple donning their droplets from daily backyard watering.
\n\n\n\n
\n\n\n\n
More flowers from my backyard include the Teddy Bear sunflower and the geranium in my deck garden.
\n\n\n\n
\n\n\n\n
Hint: I’m sure it’s no secret, but if you need an image with water droplets pronto, use a mister or spray bottle and create your own droplets.
\n\n\n\n
If close-ups of water droplets aren’t your style, I included a couple of shots of suspended water drops:
\n\n\n\n
Catching a wall of water drops in Baja Mexico.
\n\n\n\n\n\n\n\n
I have to share this one again of Brodie romping through the river, stirring up loads of droplets.
\n\n\n\n\n\n\n\n
No doubt, Spring has sprung in the Southern Hemisphere along with lots of opportunities to see water droplets in action. The calendar says that Autumn is here in the Northern Hemisphere, but we won’t see scenes like this in California until November!
\n\n\n\n\n\n\n\n
As you know I also love taking part in other photo challenges and I love it when the planets align. I have been wanting to join Lisa’s Bird Weekly Challenge since I recently discovered it. Back in August, my lens captured this cute duck family (common merganser) out for a swim on Grant Lake in the June Lake loop in the Eastern Sierra Nevadas.
\n\n\n\n\n\n\n\n
Yes, it’s a bit of a stretch but my Canon Sureshot managed to capture water droplets on the feathers of the adult duck, seen best in the pic below. (Best I could do, Lisa, I think their legs are short enough)!
I’m hoping to live vicariously through your wonderful images of water droplets on flowers, plants, animals, whatever! Walls of water droplets cascading down from waves and fountains and other watery images also fit this week’s theme.
\n\n\n
Sunday Stills Photo Challenge Reminders
\n\n
Please create a new post for the theme.
Title your post a little differently than mine.
Don’t forget to create a pingback to this post so that other participants can read your post. I also recommend adding your post’s URL into the comments.
Entries for this theme can be shared all week. Use hashtag #SundayStills for sharing on social media.
My return to blogging was fun and satisfying after a tumultuous break! Thank you for welcoming me back last week. It was great to catch up with you! If you are confused by this week’s Sunday Stills post, we are examining the world of water droplets. In my dry, still excessively warm part of the […]
In between the shadows, In the midst of darkness And grief , I desire to survive, I persevere hollowness of life. My strength is that Ray of sunshine who Itself is persevering, Piercing through darkness And my grief, breaking barriers And finally reaches me !
The time of the year when the Sun shines directly on the Equator and the length of day and night is almost equal, marking the first day of Autumn in the Northern Hemisphere and the first day of Spring in the Southern Hemisphere.
\n\n\n\n
Autumn over here and my favorite season too. There’s a special kind of quiet this season, accompanied by a burst of fiery colors, very dear to me.
\n\n\n\n
○
\n\n\n\n
Moon Phase: Waxing Crescent • Visible: 33%↑ • Age: 5,72 days • Moon Distance: 368,382.09 km
\n\n\n\n
●
\n\n\n\n
This Equinox,
\n\n\n\n
I decided to do
\n\n\n\n
the Sun and the Moon,
\n\n\n\n
side by side
\n\n\n\n
in similar …moods.
\n\n\n\n\n\n\n\n
● Sun and clouds
\n\n\n\n
\n\n\n\n
○ Moon and clouds
\n\n\n\n
\n\n\n\n
● Sun bokeh
\n\n\n\n
\n\n\n\n
○ Moon bokeh
\n\n\n\n
\n\n\n\n\n\n\n\n\n\n\n\n
●○
\n\n\n\n
Finally, a day beginning and a day ending,
\n\n\n\n
before they merge into one image [top]
\n\n\n\n
● Sunrise
\n\n\n\n
\n\n\n\n
● Sunset
\n\n\n\n
\n\n\n\n\n\n\n\n
●○
\n\n\n\n
Jean-Michel Jarre
\n\n\n\n
Équinoxe , Pt. 2
\n\n\n\n
Équinoxe, 1978
\n\n\n\n
\n\n
\n\n\n\n
●○
\n\n\n\n
Happy
\n\n\n\n
and safe
\n\n\n\n
Fall Equinox
\n\n\n\n
everyone!
\n\n\n\n
○●
\n\n\n\n\n",
+ "excerpt": "
The time of the year when the Sun shines directly on the Equator and the length of day and night is almost equal, marking the first day of Autumn in the Northern Hemisphere and the first day of Spring in the Southern Hemisphere. Autumn over here and my favorite season too. There’s a special kind […]
It took pain, anxiety and sadness for me to realize I needed to be kind to myself.
\n\n\n\nOur first Fall Harvest Blessing Tree. We’re skipping the pumpkin patch this year. Instead, we went to Ross, Marshall’s and Dollar Tree for our pumpkin hunting. If we choose to see and celebrate what’s good of 2020, then it has been a good year. \n\n\n\n
In this world, our happiness will not be given easily. In fact, people will find a way to take it. So, today care less of what others will say. Be nice to yourself instead of trying to please everyone. People will take advantage of your kindness and will tear your joy and positivity in pieces.
\n\n\n\nThese pumpkins light up our dining room. We don’t wait for light to happen. We find light where we can. \n\n\n\n
Fall in love with your life because no one can love it more than you do.
\n\n\n\n
Fall in love with your life because it deserves to be happy too.
\n\n\n\nI can only change how I react. \n\n\n\n
It’s never too late to feel like we matter again. You’d been a light and warmth to others. It’s okay to experience the same.
\n\n\n\nFamily Day in San Francisco. Stopping to watch the ocean before heading down Union Square. I turned down 3 overtime work including today and tomorrow. Money won’t make you happy, but following your heart will. 9/19/20\n\n\n\n
Today, being better begins.
\n\n\n\n\n",
+ "excerpt": "
It took pain, anxiety and sadness for me to realize I needed to be kind to myself. In this world, our happiness will not be given easily. In fact, people will find a way to take it. So, today care less of what others will say. Be nice to yourself instead of trying to please […]
Can't be bothered to cook but still craving delicious food? We've got you covered with our ready made meals. Our menu offers selections for breakfast, lunch, or dinner with a range that is sure to please the whole family.
\n
\n\n\n\n
\n
Birthdays
\n\n\n\n
Birthday parties are a special occasion for family and friends to gather together and celebrate. We can help you make the party even better with our delicious birthday party catering service.
\n
\n\n\n\n
\n
Weddings
\n\n\n\n
Let us make your special day even more memorable with an exquisite menu uniquely tailored to your vision and budget. We would be delighted to work with you and be a part of your celebration.
\n
\n
\n\n\n\n\n
\n\n\n\n\n\n\n\n\n\n\n\n
Thank you so much for helping make our day so wonderful! Everyone enjoyed the beautifully presented food and the professional service. Thank you again!
Hi I'm Jane, and if you haven't noticed already, I really really like to draw! I've been working in the visual arts for more than eight years. My portfolio includes print and digital media, clothing and kids' books. Thanks for visiting!
We stock the largest and most unique range of classic and vintage cameras and accessories anywhere. Looking for film processing and darkroom equipment? We've got you covered!
Professional and amateur astrophotographers alike will love our new collection of deep field lenses. With quality housing, shake-eliminating focus barrels and world-class optics, you'll be able to capture the Milky Way and our Solar System in all their magnificence. The extra wide angle and aperture provide outstanding quality and low noise. These pieces come with cleaning cloths and felt cases and a 12-month warranty.
If you're new to photography, you may have heard the term 'depth of field', but might be still unsure of how it affects your shot set up. Even older, vintage cameras have features that allow you to increase or decrease depth of field.
\n
\n\n\n\n\n
\n
\n\n\n\n
\n
Professional high quality cameras, tools, and accessories for today’s avid photographers.
\n
\n\n\n\n\n\n\n\n
What Our Customers are Saying
\n\n\n\n\n\n\n\n
I don't leave the house without my stylish camera. Thank you for your advice and assistance.
We are a local organisation that provides support and expertise within the local community, to potential volunteers, existing volunteers, and organisations that involve volunteers.
\n\n\n\n\n\n\n\n
\n\n\n\n\n
\n
\n
1,652
\n\n\n\n
Volunteers available
\n
\n\n\n\n
\n
1,132
\n\n\n\n
Volunteer opportunities
\n
\n\n\n\n
\n
1,972
\n\n\n\n
Matches last year
\n
\n
\n\n\n\n\n
\n\n\n\n
\n\n\n\n\n
Are you a business?
\n\n\n\n
We are uniting our resources around this challenge, and we are combining our resources and asks to make it easy for people to support their communities.
We’ve had an incredible response so far, and are doing everything we can to respond to everyone who wants to volunteer in one of our community programmes.
Hi, I’m Lillie. Previously a magazine editor, I became a full-time mother and freelance writer in 2017. When I’m not spending time with my wonderful kids and husband, I love writing about my fascination with food, adventure, and living a healthy and organized life!
We are a non-profit organization, we are looking forward to a peaceful world by helping each other to join hands together to bring a better future for all children.
We believe in a world where every child can read. Our mission is to invest in early childhood education in order to empower the next generation. We do that, by creating educational programs and providing necessary resources in underprivileged areas. We believe in smart fund allocation, and therefore we employ minimal staff.
\n
\n
\n\n\n\n\n
\n\n\n\n
\n\n\n\n\n
How You Can Help
\n\n\n\n\n\n\n\n
\n
\n
Send Donation
\n\n\n\n
Giving online has never been more secure, convenient or hassle-free with our one-click donation. We also do accept standard cash and check donations at all of our locations.
\n
\n\n\n\n
\n
Become a Volunteer
\n\n\n\n
You can get involved today by becoming a Volunteer. Sign up and you will be joining a group of change-makers, a network strong enough to impact positive change in the lives of children.
\n
\n\n\n\n
\n
Give Scholarship
\n\n\n\n
Your gift will help equip children in need with necessary resources, training and education while offering the promise of a brighter future. You can make a difference today by signing up.
\n
\n
\n\n\n\n\n
\n\n\n\n
\n\n\n\n\n
Testimonials
\n\n\n\n
What People Say
\n\n\n\n\n\n\n\n
\n
\n
The generosity of this organization makes it possible for me to continue having education opportunities in this difficult area.
Jane Doe
\n
\n\n\n\n
\n
Wonderful job with those kids that need us and people from various places all over the world. I will definitely join your group as a volunteer.
John Doe
\n
\n
\n\n\n\n\n
\n\n\n\n
\n\n\n\n\n
News
\n\n\n\n
Recent Causes
\n\n\n\n\n\n\n\n\n\n\n
\n\n\n\n
\n\n\n\n\n
Become a Volunteer
\n\n\n\n
With the aim of helping as many people as possible, we always lack enthusiastic volunteers. Please contact us for more info.
We are a small team of talented professionals with a wide range of skills and experience. We love what we do, and we do it with passion. We look forward to working with you.
Eat Dessert First is for my love of food and sharing my favorites with you.
\n\n\n\n\n\n\n\n
Hi, I’m Lillie. Previously a magazine editor, I became a full-time mother and freelance writer in 2017. I spend most of my time with my kids and husband over at The Brown Bear Family.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.
\n\n\n\n\n\n\n\n
Service B
\n\n\n\n
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.
\n
\n
\n\n\n\n
\n
\n
Testimonials
\n\n\n\n\n
\n\n\n\n
\n
Add a testimonial from someone who loves your service. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.
Jane Doe
\n\n\n\n\n\n\n\n
Add a testimonial from someone who loves your service. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.
Use this space to introduce yourself. Who you are, what you do, and where you are.
\n\n\n\n
Add more information about yourself. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh.
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n
Project Name
\n\n\n\n
A short Description of your project
\n
\n\n\n\n\n\n\n\n
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vitae massa eu nisi vulputate congue ut a tellus. Proin accumsan purus et dui venenatis, malesuada fermentum sem efficitur. Sed dignissim tristique congue. Vestibulum neque augue, varius id finibus vel, cursus eget arcu. Aliquam at nulla diam. Integer faucibus, libero at lacinia sollicitudin, elit nisi iaculis velit, non malesuada ante est vel ex. Phasellus id feugiat leo. Donec quis tortor dolor.
\n\n\n\n\n\n\n\n
Add a caption
Add a caption
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n
Project Name
\n\n\n\n
A short Description of your project
\n
\n\n\n\n\n\n\n\n
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vitae massa eu nisi vulputate congue ut a tellus. Proin accumsan purus et dui venenatis, malesuada fermentum sem efficitur. Sed dignissim tristique congue. Vestibulum neque augue, varius id finibus vel, cursus eget arcu. Aliquam at nulla diam. Integer faucibus, libero at lacinia sollicitudin, elit nisi iaculis velit, non malesuada ante est vel ex. Phasellus id feugiat leo. Donec quis tortor dolor.
Use this space to introduce yourself. Who you are, what you do, and where you are.
\n\n\n\n\n\n\n\n
Add more information about yourself. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh.
Tell your visitors how to contact you. You could also get a bit more specific, by letting them know what to contact you about.
\n\n\n\n
Be sure to mention all the channels where visitors can reach you, including social media. If you’d rather not provide your email, you can add a contact form instead.
Add more information about yourself. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.
\n
\n
\n\n\n\n
\n
\n
Expertise
\n\n\n\n\n
\n\n\n\n
\n
What You Do
\n\n\n\n
Add more information about what you do. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh.
\n\n\n\n\n\n\n\n
What You Do
\n\n\n\n
Add more information about what you do. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh.
We are a small team of talented professionals with a wide range of skills and experience. We love what we do, and we do it with passion. We look forward to working with you.
\n\n\n\n\n\n\n\n
\n
Sally Smith
\n\n\n\n
Position or Job Title
\n\n\n\n
A short bio with personal history, key achievements, or an interesting fact.
Visitors will want to know who is on the other side of the page. Use this space to write about yourself, your site, your business, or anything you want. Use the testimonials below to quote others, talking about the same thing – in their own words.
\n\n\n\n
This is sample content, included with the template to illustrate its features. Remove or replace it with your own words and media.
\n\n\n\n
What People Say
\n\n\n\n
\n
\n
The way to get started is to quit talking and begin doing.
Walt Disney
\n
\n\n\n\n
\n
It is our choices, Harry, that show what we truly are, far more than our abilities.
J.K. Rowling
\n
\n\n\n\n
\n
Don’t cry because it’s over, smile because it happened.
Tell your visitors how to contact you. You could also get a bit more specific, by letting them know what to contact you about.
\n\n\n\n
Be sure to mention all the channels where visitors can reach you, including social media. If you’d rather not provide your email, you can add a contact form instead.
Tell your visitors how to get in touch with you. You could also get a bit more specific, by letting them know what to contact you about.
\n\n\n\n
Be sure to mention all the channels where visitors can reach you, including social media. If you'd rather not provide your email, you can add a contact form instead.
Add more information about yourself. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In sit amet eros eget justo elementum interdum. Cras vestibulum nulla id aliquam rutrum. Vestibulum aliquet mauris ut augue ultrices facilisis. Vestibulum pretium ligula sed ipsum dapibus, tempus iaculis felis ornare. Morbi pretium sed est tincidunt hendrerit. Curabitur id elit scelerisque, pharetra tellus sit amet, dictum mi. Aliquam consectetur tristique metus non pulvinar. Donec luctus magna quis justo tincidunt, eu euismod lacus faucibus.
\n",
+ "attribution": "dayone",
+ "date": "2022-07-26",
+ "answered": false,
+ "answered_users_count": 0,
+ "answered_users_sample": []
+ },
+ {
+ "id": 2010,
+ "text": "What's the story behind your nickname?",
+ "title": "Prompt number 207",
+ "content": "\n
What's the story behind your nickname?
\n",
+ "attribution": "dayone",
+ "date": "2022-07-27",
+ "answered": false,
+ "answered_users_count": 0,
+ "answered_users_sample": []
+ },
+ {
+ "id": 2011,
+ "text": "If you won two free plane tickets, where would you go?",
+ "title": "Prompt number 208",
+ "content": "\n
If you won two free plane tickets, where would you go?
\n",
+ "attribution": "",
+ "date": "2022-07-28",
+ "answered": false,
+ "answered_users_count": 0,
+ "answered_users_sample": []
+ },
+ {
+ "id": 2012,
+ "text": "If you could bring back one dinosaur, which one would it be?",
+ "title": "Prompt number 209",
+ "content": "\n
If you could bring back one dinosaur, which one would it be?
\n",
+ "attribution": "dayone",
+ "date": "2022-07-29",
+ "answered": false,
+ "answered_users_count": 0,
+ "answered_users_sample": []
+ },
+ {
+ "id": 2013,
+ "text": "How would you describe yourself to someone?",
+ "title": "Prompt number 210",
+ "content": "\n
, since we've promoted it to
- styled_html = styled_html.sub("
Acknowledgements
", '')
-
- ## The glog library's license contains a URL that does not wrap in the web view,
- ## leading to a large right-hand whitespace gutter. Work around this by explicitly
- ## inserting a in the HTML. Use gsub juuust in case another one sneaks in later.
- styled_html = styled_html.gsub('p?hl=en#dR3YEbitojA/COPYING', 'p?hl=en#dR3YEbitojA/COPYING ')
-
- File.write("#{project_root}/Pods/Target Support Files/Pods-WordPress/acknowledgements.html", styled_html)
- end
+ ## Jetpack App iOS
+ ## ===============
+ ##
+ target 'Jetpack'
end
-
## Share Extension
## ===============
##
target 'WordPressShareExtension' do
- project 'WordPress/WordPress.xcodeproj'
+ project 'WordPress/WordPress.xcodeproj'
- shared_with_extension_pods
+ shared_with_extension_pods
- aztec
- shared_with_all_pods
- shared_with_networking_pods
- wordpress_ui
+ aztec
+ shared_with_all_pods
+ shared_with_networking_pods
+ wordpress_ui
end
+target 'JetpackShareExtension' do
+ project 'WordPress/WordPress.xcodeproj'
+
+ shared_with_extension_pods
+
+ aztec
+ shared_with_all_pods
+ shared_with_networking_pods
+ wordpress_ui
+end
## DraftAction Extension
## =====================
##
target 'WordPressDraftActionExtension' do
- project 'WordPress/WordPress.xcodeproj'
+ project 'WordPress/WordPress.xcodeproj'
- shared_with_extension_pods
+ shared_with_extension_pods
- aztec
- shared_with_all_pods
- shared_with_networking_pods
- wordpress_ui
+ aztec
+ shared_with_all_pods
+ shared_with_networking_pods
+ wordpress_ui
end
+target 'JetpackDraftActionExtension' do
+ project 'WordPress/WordPress.xcodeproj'
-## Today Widget
-## ============
-##
-target 'WordPressTodayWidget' do
- project 'WordPress/WordPress.xcodeproj'
-
- shared_with_all_pods
- shared_with_networking_pods
+ shared_with_extension_pods
- wordpress_ui
+ aztec
+ shared_with_all_pods
+ shared_with_networking_pods
+ wordpress_ui
end
-## All Time Widget
+## Home Screen Widgets
## ============
##
-target 'WordPressAllTimeWidget' do
- project 'WordPress/WordPress.xcodeproj'
+target 'WordPressStatsWidgets' do
+ project 'WordPress/WordPress.xcodeproj'
- shared_with_all_pods
- shared_with_networking_pods
+ shared_with_all_pods
+ shared_with_networking_pods
+ shared_style_pods
- wordpress_ui
+ wordpress_ui
end
-## This Week Widget
-## ============
-##
-target 'WordPressThisWeekWidget' do
- project 'WordPress/WordPress.xcodeproj'
+target 'JetpackStatsWidgets' do
+ project 'WordPress/WordPress.xcodeproj'
- shared_with_all_pods
- shared_with_networking_pods
+ shared_with_all_pods
+ shared_with_networking_pods
+ shared_style_pods
- wordpress_ui
+ wordpress_ui
end
-## Notification Content Extension
-## ==============================
+## Intents
+## ============
##
-target 'WordPressNotificationContentExtension' do
- project 'WordPress/WordPress.xcodeproj'
+target 'WordPressIntents' do
+ project 'WordPress/WordPress.xcodeproj'
- wordpress_kit
- wordpress_shared
- wordpress_ui
+ shared_with_all_pods
+ shared_with_networking_pods
+
+ wordpress_ui
end
+target 'JetpackIntents' do
+ project 'WordPress/WordPress.xcodeproj'
+
+ shared_with_all_pods
+ shared_with_networking_pods
+ wordpress_ui
+end
## Notification Service Extension
## ==============================
##
target 'WordPressNotificationServiceExtension' do
- project 'WordPress/WordPress.xcodeproj'
+ project 'WordPress/WordPress.xcodeproj'
- wordpress_kit
- wordpress_shared
- wordpress_ui
+ wordpress_kit
+ wordpress_shared
+ wordpress_ui
end
+target 'JetpackNotificationServiceExtension' do
+ project 'WordPress/WordPress.xcodeproj'
-## Mocks
-## ===================
-##
-def wordpress_mocks
- pod 'WordPressMocks', '~> 0.0.8'
- # pod 'WordPressMocks', :git => 'https://github.com/wordpress-mobile/WordPressMocks.git', :commit => ''
- # pod 'WordPressMocks', :git => 'https://github.com/wordpress-mobile/WordPressMocks.git', :branch => 'add/screenshot-mocks'
- # pod 'WordPressMocks', :path => '../WordPressMocks'
+ wordpress_kit
+ wordpress_shared
+ wordpress_ui
end
-
## Screenshot Generation
## ===================
##
target 'WordPressScreenshotGeneration' do
- project 'WordPress/WordPress.xcodeproj'
-
- wordpress_mocks
- pod 'SimulatorStatusMagic'
+ project 'WordPress/WordPress.xcodeproj'
end
## UI Tests
## ===================
##
target 'WordPressUITests' do
- project 'WordPress/WordPress.xcodeproj'
+ project 'WordPress/WordPress.xcodeproj'
+end
- wordpress_mocks
+abstract_target 'Tools' do
+ pod 'SwiftLint', '~> 0.50'
end
# Static Frameworks:
@@ -382,25 +399,112 @@ end
# A future version of CocoaPods may make this easier to do. See https://github.com/CocoaPods/CocoaPods/issues/7428
shared_targets = ['WordPressFlux']
pre_install do |installer|
- static = []
- dynamic = []
- installer.pod_targets.each do |pod|
-
- # Statically linking Sentry results in a conflict with `NSDictionary.objectAtKeyPath`, but dynamically
- # linking it resolves this.
- if pod.name == "Sentry"
- dynamic << pod
- next
- end
-
- # If this pod is a dependency of one of our shared targets, it must be linked dynamically
- if pod.target_definitions.any? { |t| shared_targets.include? t.name }
- dynamic << pod
- next
- end
- static << pod
- pod.instance_variable_set(:@build_type, Pod::Target::BuildType.static_framework)
+ static = []
+ dynamic = []
+ installer.pod_targets.each do |pod|
+ # Statically linking Sentry results in a conflict with `NSDictionary.objectAtKeyPath`, but dynamically
+ # linking it resolves this.
+ if %w[Sentry SentryPrivate].include? pod.name
+ dynamic << pod
+ next
+ end
+
+ # If this pod is a dependency of one of our shared targets, it must be linked dynamically
+ if pod.target_definitions.any? { |t| shared_targets.include? t.name }
+ dynamic << pod
+ next
+ end
+ static << pod
+ pod.instance_variable_set(:@build_type, Pod::BuildType.static_framework)
+ end
+ puts "Installing #{static.count} pods as static frameworks"
+ puts "Installing #{dynamic.count} pods as dynamic frameworks"
+end
+
+post_install do |installer|
+ project_root = File.dirname(__FILE__)
+
+ ## Convert the 3rd-party license acknowledgements markdown into html for use in the app
+ require 'commonmarker'
+
+ acknowledgements = 'Acknowledgments'
+ markdown = File.read("#{project_root}/Pods/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress-acknowledgements.markdown")
+ rendered_html = CommonMarker.render_html(markdown, :DEFAULT)
+ styled_html = "
+
+
+
+ #{acknowledgements}
+
+
+
+ #{rendered_html}
+ "
+
+ ## Remove the
, since we've promoted it to
+ styled_html = styled_html.sub('
Acknowledgements
', '')
+
+ ## The glog library's license contains a URL that does not wrap in the web view,
+ ## leading to a large right-hand whitespace gutter. Work around this by explicitly
+ ## inserting a in the HTML. Use gsub juuust in case another one sneaks in later.
+ styled_html = styled_html.gsub('p?hl=en#dR3YEbitojA/COPYING', 'p?hl=en#dR3YEbitojA/COPYING ')
+
+ File.write("#{project_root}/Pods/Target Support Files/Pods-Apps-WordPress/acknowledgements.html", styled_html)
+
+ # Let Pods targets inherit deployment target from the app
+ # This solution is suggested here: https://github.com/CocoaPods/CocoaPods/issues/4859
+ # =====================================
+ #
+ installer.pods_project.targets.each do |target|
+ # Exclude RCT-Folly as it requires explicit deployment target https://git.io/JPb73
+ next unless target.name != 'RCT-Folly'
+
+ target.build_configurations.each do |configuration|
+ pod_ios_deployment_target = Gem::Version.new(configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'])
+ configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if pod_ios_deployment_target <= app_ios_deployment_target
+ end
+ end
+
+ # Fix a code signing issue in Xcode 14 beta.
+ # This solution is suggested here: https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1189861270
+ # ====================================
+ #
+ # TODO: fix the linting issue if this workaround is still needed in Xcode 14 GM.
+ # rubocop:disable Style/CombinableLoops
+ installer.pods_project.targets.each do |target|
+ target.build_configurations.each do |config|
+ config.build_settings['CODE_SIGN_IDENTITY'] = ''
end
- puts "Installing #{static.count} pods as static frameworks"
- puts "Installing #{dynamic.count} pods as dynamic frameworks"
+ end
+ # rubocop:enable Style/CombinableLoops
+
+ # Flag Alpha builds for Tracks
+ # ============================
+ #
+ tracks_target = installer.pods_project.targets.find { |target| target.name == 'Automattic-Tracks-iOS' }
+ # This will crash if/when we'll remove Tracks.
+ # That's okay because it is a crash we'll only have to address once.
+ tracks_target.build_configurations.each do |config|
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)', 'ALPHA=1'] if (config.name == 'Release-Alpha') || (config.name == 'Release-Internal')
+ end
+
+ yellow_marker = "\033[33m"
+ reset_marker = "\033[0m"
+ puts "#{yellow_marker}The abstract target warning below is expected. Feel free to ignore it.#{reset_marker}"
end
diff --git a/Podfile.lock b/Podfile.lock
index ed07ade03b5e..24d215e41afb 100644
--- a/Podfile.lock
+++ b/Podfile.lock
@@ -1,78 +1,77 @@
PODS:
- - 1PasswordExtension (1.8.5)
- Alamofire (4.8.0)
+ - AlamofireImage (3.5.2):
+ - Alamofire (~> 4.8)
- AlamofireNetworkActivityIndicator (2.4.0):
- Alamofire (~> 4.8)
- - AppCenter (2.5.1):
- - AppCenter/Analytics (= 2.5.1)
- - AppCenter/Crashes (= 2.5.1)
- - AppCenter/Analytics (2.5.1):
+ - AppAuth (1.6.1):
+ - AppAuth/Core (= 1.6.1)
+ - AppAuth/ExternalUserAgent (= 1.6.1)
+ - AppAuth/Core (1.6.1)
+ - AppAuth/ExternalUserAgent (1.6.1):
+ - AppAuth/Core
+ - AppCenter (4.4.1):
+ - AppCenter/Analytics (= 4.4.1)
+ - AppCenter/Crashes (= 4.4.1)
+ - AppCenter/Analytics (4.4.1):
- AppCenter/Core
- - AppCenter/Core (2.5.1)
- - AppCenter/Crashes (2.5.1):
+ - AppCenter/Core (4.4.1)
+ - AppCenter/Crashes (4.4.1):
- AppCenter/Core
- - AppCenter/Distribute (2.5.1):
+ - AppCenter/Distribute (4.4.1):
- AppCenter/Core
- - Automattic-Tracks-iOS (0.4.3):
- - CocoaLumberjack (~> 3.5.2)
- - Reachability (~> 3.1)
- - Sentry (~> 4)
- - UIDeviceIdentifier (~> 1)
- - boost-for-react-native (1.63.0)
- - Charts (3.2.2):
- - Charts/Core (= 3.2.2)
- - Charts/Core (3.2.2)
- - CocoaLumberjack (3.5.2):
- - CocoaLumberjack/Core (= 3.5.2)
- - CocoaLumberjack/Core (3.5.2)
+ - Automattic-Tracks-iOS (2.2.0):
+ - Sentry (~> 8.0)
+ - Sodium (>= 0.9.1)
+ - UIDeviceIdentifier (~> 2.0)
+ - boost (1.76.0)
+ - BVLinearGradient (2.5.6-wp-3):
+ - React-Core
+ - CocoaLumberjack/Core (3.8.0)
+ - CocoaLumberjack/Swift (3.8.0):
+ - CocoaLumberjack/Core
+ - CropViewController (2.5.3)
- DoubleConversion (1.1.5)
- Down (0.6.6)
- - FBLazyVector (0.61.5)
- - FBReactNativeSpec (0.61.5):
- - Folly (= 2018.10.22.00)
- - RCTRequired (= 0.61.5)
- - RCTTypeSafety (= 0.61.5)
- - React-Core (= 0.61.5)
- - React-jsi (= 0.61.5)
- - ReactCommon/turbomodule/core (= 0.61.5)
- - Folly (2018.10.22.00):
- - boost-for-react-native
- - DoubleConversion
- - Folly/Default (= 2018.10.22.00)
- - glog
- - Folly/Default (2018.10.22.00):
- - boost-for-react-native
- - DoubleConversion
- - glog
- - FormatterKit/Resources (1.8.2)
- - FormatterKit/TimeIntervalFormatter (1.8.2):
- - FormatterKit/Resources
+ - FBLazyVector (0.69.4)
+ - FBReactNativeSpec (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - RCTRequired (= 0.69.4)
+ - RCTTypeSafety (= 0.69.4)
+ - React-Core (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - fmt (6.2.1)
- FSInteractiveMap (0.1.0)
- Gifu (3.2.0)
- glog (0.3.5)
- - GoogleSignIn (4.4.0):
- - "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
- - "GoogleToolboxForMac/NSString+URLArguments (~> 2.1)"
+ - GoogleSignIn (6.0.2):
+ - AppAuth (~> 1.4)
+ - GTMAppAuth (~> 1.0)
- GTMSessionFetcher/Core (~> 1.1)
- - GoogleToolboxForMac/DebugUtils (2.2.2):
- - GoogleToolboxForMac/Defines (= 2.2.2)
- - GoogleToolboxForMac/Defines (2.2.2)
- - "GoogleToolboxForMac/NSDictionary+URLArguments (2.2.2)":
- - GoogleToolboxForMac/DebugUtils (= 2.2.2)
- - GoogleToolboxForMac/Defines (= 2.2.2)
- - "GoogleToolboxForMac/NSString+URLArguments (= 2.2.2)"
- - "GoogleToolboxForMac/NSString+URLArguments (2.2.2)"
- - Gridicons (0.19)
- - GTMSessionFetcher/Core (1.3.1)
- - Gutenberg (1.23.0):
- - React (= 0.61.5)
- - React-CoreModules (= 0.61.5)
- - React-RCTImage (= 0.61.5)
+ - Gridicons (1.1.0)
+ - GTMAppAuth (1.3.1):
+ - AppAuth/Core (~> 1.6)
+ - GTMSessionFetcher/Core (< 3.0, >= 1.5)
+ - GTMSessionFetcher/Core (1.7.2)
+ - Gutenberg (1.94.0):
+ - React (= 0.69.4)
+ - React-CoreModules (= 0.69.4)
+ - React-RCTImage (= 0.69.4)
- RNTAztecView
- - JTAppleCalendar (8.0.2)
- - lottie-ios (2.5.2)
- - MediaEditor (1.0.1):
- - TOCropViewController (~> 2.5.2)
+ - JTAppleCalendar (8.0.3)
+ - Kanvas (1.4.4)
+ - libwebp (1.2.4):
+ - libwebp/demux (= 1.2.4)
+ - libwebp/mux (= 1.2.4)
+ - libwebp/webp (= 1.2.4)
+ - libwebp/demux (1.2.4):
+ - libwebp/webp
+ - libwebp/mux (1.2.4):
+ - libwebp/demux
+ - libwebp/webp (1.2.4)
+ - MediaEditor (1.2.1):
+ - CropViewController (~> 2.5.3)
- MRProgress (0.8.3):
- MRProgress/ActivityIndicator (= 0.8.3)
- MRProgress/Blur (= 0.8.3)
@@ -101,430 +100,568 @@ PODS:
- MRProgress/ProgressBaseClass (0.8.3)
- MRProgress/Stopable (0.8.3):
- MRProgress/Helper
- - Nimble (7.3.4)
- - NSObject-SafeExpectations (0.0.3)
- - "NSURL+IDN (0.3)"
+ - NSObject-SafeExpectations (0.0.4)
+ - "NSURL+IDN (0.4)"
- OCMock (3.4.3)
- - OHHTTPStubs (6.1.0):
- - OHHTTPStubs/Default (= 6.1.0)
- - OHHTTPStubs/Core (6.1.0)
- - OHHTTPStubs/Default (6.1.0):
+ - OHHTTPStubs/Core (9.1.0)
+ - OHHTTPStubs/Default (9.1.0):
- OHHTTPStubs/Core
- OHHTTPStubs/JSON
- OHHTTPStubs/NSURLSession
- OHHTTPStubs/OHPathHelpers
- - OHHTTPStubs/JSON (6.1.0):
+ - OHHTTPStubs/JSON (9.1.0):
- OHHTTPStubs/Core
- - OHHTTPStubs/NSURLSession (6.1.0):
+ - OHHTTPStubs/NSURLSession (9.1.0):
- OHHTTPStubs/Core
- - OHHTTPStubs/OHPathHelpers (6.1.0)
- - OHHTTPStubs/Swift (6.1.0):
+ - OHHTTPStubs/OHPathHelpers (9.1.0)
+ - OHHTTPStubs/Swift (9.1.0):
- OHHTTPStubs/Default
- - RCTRequired (0.61.5)
- - RCTTypeSafety (0.61.5):
- - FBLazyVector (= 0.61.5)
- - Folly (= 2018.10.22.00)
- - RCTRequired (= 0.61.5)
- - React-Core (= 0.61.5)
+ - RCT-Folly (2021.06.28.00-v2):
+ - boost
+ - DoubleConversion
+ - fmt (~> 6.2.1)
+ - glog
+ - RCT-Folly/Default (= 2021.06.28.00-v2)
+ - RCT-Folly/Default (2021.06.28.00-v2):
+ - boost
+ - DoubleConversion
+ - fmt (~> 6.2.1)
+ - glog
+ - RCTRequired (0.69.4)
+ - RCTTypeSafety (0.69.4):
+ - FBLazyVector (= 0.69.4)
+ - RCTRequired (= 0.69.4)
+ - React-Core (= 0.69.4)
- Reachability (3.2)
- - React (0.61.5):
- - React-Core (= 0.61.5)
- - React-Core/DevSupport (= 0.61.5)
- - React-Core/RCTWebSocket (= 0.61.5)
- - React-RCTActionSheet (= 0.61.5)
- - React-RCTAnimation (= 0.61.5)
- - React-RCTBlob (= 0.61.5)
- - React-RCTImage (= 0.61.5)
- - React-RCTLinking (= 0.61.5)
- - React-RCTNetwork (= 0.61.5)
- - React-RCTSettings (= 0.61.5)
- - React-RCTText (= 0.61.5)
- - React-RCTVibration (= 0.61.5)
- - React-Core (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React (0.69.4):
+ - React-Core (= 0.69.4)
+ - React-Core/DevSupport (= 0.69.4)
+ - React-Core/RCTWebSocket (= 0.69.4)
+ - React-RCTActionSheet (= 0.69.4)
+ - React-RCTAnimation (= 0.69.4)
+ - React-RCTBlob (= 0.69.4)
+ - React-RCTImage (= 0.69.4)
+ - React-RCTLinking (= 0.69.4)
+ - React-RCTNetwork (= 0.69.4)
+ - React-RCTSettings (= 0.69.4)
+ - React-RCTText (= 0.69.4)
+ - React-RCTVibration (= 0.69.4)
+ - React-bridging (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-jsi (= 0.69.4)
+ - React-callinvoker (0.69.4)
+ - React-Codegen (0.69.4):
+ - FBReactNativeSpec (= 0.69.4)
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - RCTRequired (= 0.69.4)
+ - RCTTypeSafety (= 0.69.4)
+ - React-Core (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-Core (0.69.4):
- glog
- - React-Core/Default (= 0.61.5)
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-Core/Default (= 0.69.4)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/CoreModulesHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/CoreModulesHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/Default (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/Default (0.69.4):
- glog
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/DevSupport (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/DevSupport (0.69.4):
- glog
- - React-Core/Default (= 0.61.5)
- - React-Core/RCTWebSocket (= 0.61.5)
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
- - React-jsinspector (= 0.61.5)
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-Core/Default (= 0.69.4)
+ - React-Core/RCTWebSocket (= 0.69.4)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-jsinspector (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTActionSheetHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTActionSheetHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTAnimationHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTAnimationHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTBlobHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTBlobHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTImageHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTImageHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTLinkingHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTLinkingHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTNetworkHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTNetworkHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTSettingsHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTSettingsHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTTextHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTTextHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTVibrationHeaders (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTVibrationHeaders (0.69.4):
- glog
+ - RCT-Folly (= 2021.06.28.00-v2)
- React-Core/Default
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-Core/RCTWebSocket (0.61.5):
- - Folly (= 2018.10.22.00)
+ - React-Core/RCTWebSocket (0.69.4):
- glog
- - React-Core/Default (= 0.61.5)
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsiexecutor (= 0.61.5)
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-Core/Default (= 0.69.4)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsiexecutor (= 0.69.4)
+ - React-perflogger (= 0.69.4)
- Yoga
- - React-CoreModules (0.61.5):
- - FBReactNativeSpec (= 0.61.5)
- - Folly (= 2018.10.22.00)
- - RCTTypeSafety (= 0.61.5)
- - React-Core/CoreModulesHeaders (= 0.61.5)
- - React-RCTImage (= 0.61.5)
- - ReactCommon/turbomodule/core (= 0.61.5)
- - React-cxxreact (0.61.5):
- - boost-for-react-native (= 1.63.0)
+ - React-CoreModules (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - RCTTypeSafety (= 0.69.4)
+ - React-Codegen (= 0.69.4)
+ - React-Core/CoreModulesHeaders (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-RCTImage (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-cxxreact (0.69.4):
+ - boost (= 1.76.0)
- DoubleConversion
- - Folly (= 2018.10.22.00)
- glog
- - React-jsinspector (= 0.61.5)
- - React-jsi (0.61.5):
- - boost-for-react-native (= 1.63.0)
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-callinvoker (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-jsinspector (= 0.69.4)
+ - React-logger (= 0.69.4)
+ - React-perflogger (= 0.69.4)
+ - React-runtimeexecutor (= 0.69.4)
+ - React-jsi (0.69.4):
+ - boost (= 1.76.0)
- DoubleConversion
- - Folly (= 2018.10.22.00)
- glog
- - React-jsi/Default (= 0.61.5)
- - React-jsi/Default (0.61.5):
- - boost-for-react-native (= 1.63.0)
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-jsi/Default (= 0.69.4)
+ - React-jsi/Default (0.69.4):
+ - boost (= 1.76.0)
- DoubleConversion
- - Folly (= 2018.10.22.00)
- glog
- - React-jsiexecutor (0.61.5):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-jsiexecutor (0.69.4):
- DoubleConversion
- - Folly (= 2018.10.22.00)
- glog
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-jsinspector (0.61.5)
- - react-native-keyboard-aware-scroll-view (0.8.7):
- - React
- - react-native-linear-gradient (2.5.6):
- - React
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-perflogger (= 0.69.4)
+ - React-jsinspector (0.69.4)
+ - React-logger (0.69.4):
+ - glog
+ - react-native-blur (3.6.1):
+ - React-Core
+ - react-native-get-random-values (1.4.0):
+ - React-Core
- react-native-safe-area (0.5.1):
- - React
- - react-native-slider (2.0.7):
- - React
- - react-native-video (4.4.1):
- React-Core
- - react-native-video/Video (= 4.4.1)
- - react-native-video/Video (4.4.1):
+ - react-native-safe-area-context (3.2.0):
+ - React-Core
+ - react-native-slider (3.0.2-wp-3):
+ - React-Core
+ - react-native-video (5.2.0-wp-5):
- React-Core
- - React-RCTActionSheet (0.61.5):
- - React-Core/RCTActionSheetHeaders (= 0.61.5)
- - React-RCTAnimation (0.61.5):
- - React-Core/RCTAnimationHeaders (= 0.61.5)
- - React-RCTBlob (0.61.5):
- - React-Core/RCTBlobHeaders (= 0.61.5)
- - React-Core/RCTWebSocket (= 0.61.5)
- - React-jsi (= 0.61.5)
- - React-RCTNetwork (= 0.61.5)
- - React-RCTImage (0.61.5):
- - React-Core/RCTImageHeaders (= 0.61.5)
- - React-RCTNetwork (= 0.61.5)
- - React-RCTLinking (0.61.5):
- - React-Core/RCTLinkingHeaders (= 0.61.5)
- - React-RCTNetwork (0.61.5):
- - React-Core/RCTNetworkHeaders (= 0.61.5)
- - React-RCTSettings (0.61.5):
- - React-Core/RCTSettingsHeaders (= 0.61.5)
- - React-RCTText (0.61.5):
- - React-Core/RCTTextHeaders (= 0.61.5)
- - React-RCTVibration (0.61.5):
- - React-Core/RCTVibrationHeaders (= 0.61.5)
- - ReactCommon (0.61.5):
- - ReactCommon/jscallinvoker (= 0.61.5)
- - ReactCommon/turbomodule (= 0.61.5)
- - ReactCommon/jscallinvoker (0.61.5):
+ - react-native-video/Video (= 5.2.0-wp-5)
+ - react-native-video/Video (5.2.0-wp-5):
+ - React-Core
+ - react-native-webview (11.6.2):
+ - React-Core
+ - React-perflogger (0.69.4)
+ - React-RCTActionSheet (0.69.4):
+ - React-Core/RCTActionSheetHeaders (= 0.69.4)
+ - React-RCTAnimation (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - RCTTypeSafety (= 0.69.4)
+ - React-Codegen (= 0.69.4)
+ - React-Core/RCTAnimationHeaders (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-RCTBlob (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-Codegen (= 0.69.4)
+ - React-Core/RCTBlobHeaders (= 0.69.4)
+ - React-Core/RCTWebSocket (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-RCTNetwork (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-RCTImage (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - RCTTypeSafety (= 0.69.4)
+ - React-Codegen (= 0.69.4)
+ - React-Core/RCTImageHeaders (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-RCTNetwork (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-RCTLinking (0.69.4):
+ - React-Codegen (= 0.69.4)
+ - React-Core/RCTLinkingHeaders (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-RCTNetwork (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - RCTTypeSafety (= 0.69.4)
+ - React-Codegen (= 0.69.4)
+ - React-Core/RCTNetworkHeaders (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-RCTSettings (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - RCTTypeSafety (= 0.69.4)
+ - React-Codegen (= 0.69.4)
+ - React-Core/RCTSettingsHeaders (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-RCTText (0.69.4):
+ - React-Core/RCTTextHeaders (= 0.69.4)
+ - React-RCTVibration (0.69.4):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-Codegen (= 0.69.4)
+ - React-Core/RCTVibrationHeaders (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - React-runtimeexecutor (0.69.4):
+ - React-jsi (= 0.69.4)
+ - ReactCommon (0.69.4):
+ - React-logger (= 0.69.4)
+ - ReactCommon/react_debug_core (= 0.69.4)
+ - ReactCommon/turbomodule (= 0.69.4)
+ - ReactCommon/react_debug_core (0.69.4):
+ - React-logger (= 0.69.4)
+ - ReactCommon/turbomodule (0.69.4):
- DoubleConversion
- - Folly (= 2018.10.22.00)
- glog
- - React-cxxreact (= 0.61.5)
- - ReactCommon/turbomodule (0.61.5):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-bridging (= 0.69.4)
+ - React-callinvoker (= 0.69.4)
+ - React-Core (= 0.69.4)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-logger (= 0.69.4)
+ - React-perflogger (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - ReactCommon/turbomodule/samples (= 0.69.4)
+ - ReactCommon/turbomodule/core (0.69.4):
- DoubleConversion
- - Folly (= 2018.10.22.00)
- glog
- - React-Core (= 0.61.5)
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - ReactCommon/jscallinvoker (= 0.61.5)
- - ReactCommon/turbomodule/core (= 0.61.5)
- - ReactCommon/turbomodule/samples (= 0.61.5)
- - ReactCommon/turbomodule/core (0.61.5):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-bridging (= 0.69.4)
+ - React-callinvoker (= 0.69.4)
+ - React-Core (= 0.69.4)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-logger (= 0.69.4)
+ - React-perflogger (= 0.69.4)
+ - ReactCommon/turbomodule/samples (0.69.4):
- DoubleConversion
- - Folly (= 2018.10.22.00)
- glog
- - React-Core (= 0.61.5)
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - ReactCommon/jscallinvoker (= 0.61.5)
- - ReactCommon/turbomodule/samples (0.61.5):
+ - RCT-Folly (= 2021.06.28.00-v2)
+ - React-bridging (= 0.69.4)
+ - React-callinvoker (= 0.69.4)
+ - React-Core (= 0.69.4)
+ - React-cxxreact (= 0.69.4)
+ - React-jsi (= 0.69.4)
+ - React-logger (= 0.69.4)
+ - React-perflogger (= 0.69.4)
+ - ReactCommon/turbomodule/core (= 0.69.4)
+ - RNCClipboard (1.9.0):
+ - React-Core
+ - RNCMaskedView (0.2.6):
+ - React-Core
+ - RNFastImage (8.5.11):
+ - React-Core
+ - SDWebImage (~> 5.11.1)
+ - SDWebImageWebPCoder (~> 0.8.4)
+ - RNGestureHandler (2.3.2-wp-2):
+ - React-Core
+ - RNReanimated (2.9.1-wp-3):
- DoubleConversion
- - Folly (= 2018.10.22.00)
+ - FBLazyVector
+ - FBReactNativeSpec
- glog
- - React-Core (= 0.61.5)
- - React-cxxreact (= 0.61.5)
- - React-jsi (= 0.61.5)
- - ReactCommon/jscallinvoker (= 0.61.5)
- - ReactCommon/turbomodule/core (= 0.61.5)
- - ReactNativeDarkMode (0.0.10):
- - React
- - RNSVG (9.13.6-gb):
- - React
- - RNTAztecView (1.23.0):
+ - RCT-Folly
+ - RCTRequired
+ - RCTTypeSafety
+ - React-callinvoker
+ - React-Core
+ - React-Core/DevSupport
+ - React-Core/RCTWebSocket
+ - React-CoreModules
+ - React-cxxreact
+ - React-jsi
+ - React-jsiexecutor
+ - React-jsinspector
+ - React-RCTActionSheet
+ - React-RCTAnimation
+ - React-RCTBlob
+ - React-RCTImage
+ - React-RCTLinking
+ - React-RCTNetwork
+ - React-RCTSettings
+ - React-RCTText
+ - ReactCommon/turbomodule/core
+ - Yoga
+ - RNScreens (2.9.0):
+ - React-Core
+ - RNSVG (9.13.6):
- React-Core
- - WordPress-Aztec-iOS (= 1.16.0)
- - Sentry (4.4.3):
- - Sentry/Core (= 4.4.3)
- - Sentry/Core (4.4.3)
- - SimulatorStatusMagic (2.4.1)
+ - RNTAztecView (1.94.0):
+ - React-Core
+ - WordPress-Aztec-iOS (~> 1.19.8)
+ - SDWebImage (5.11.1):
+ - SDWebImage/Core (= 5.11.1)
+ - SDWebImage/Core (5.11.1)
+ - SDWebImageWebPCoder (0.8.5):
+ - libwebp (~> 1.0)
+ - SDWebImage/Core (~> 5.10)
+ - Sentry (8.6.0):
+ - Sentry/Core (= 8.6.0)
+ - SentryPrivate (= 8.6.0)
+ - Sentry/Core (8.6.0):
+ - SentryPrivate (= 8.6.0)
+ - SentryPrivate (8.6.0)
+ - Sodium (0.9.1)
- Starscream (3.0.6)
- SVProgressHUD (2.2.5)
- - TOCropViewController (2.5.2)
- - UIDeviceIdentifier (1.4.0)
- - WordPress-Aztec-iOS (1.16.0)
- - WordPress-Editor-iOS (1.16.0):
- - WordPress-Aztec-iOS (= 1.16.0)
- - WordPressAuthenticator (1.10.9-beta.1):
- - 1PasswordExtension (= 1.8.5)
- - Alamofire (= 4.8)
- - CocoaLumberjack (~> 3.5)
- - GoogleSignIn (~> 4.4)
- - Gridicons (~> 0.15)
- - lottie-ios (= 2.5.2)
- - "NSURL+IDN (= 0.3)"
- - SVProgressHUD (= 2.2.5)
- - WordPressKit (~> 4.5.9-beta)
- - WordPressShared (~> 1.8.13-beta)
- - WordPressUI (~> 1.4-beta.1)
- - WordPressKit (4.5.9-beta.2):
+ - SwiftLint (0.50.3)
+ - UIDeviceIdentifier (2.3.0)
+ - WordPress-Aztec-iOS (1.19.8)
+ - WordPress-Editor-iOS (1.19.8):
+ - WordPress-Aztec-iOS (= 1.19.8)
+ - WordPressAuthenticator (6.2.0):
+ - GoogleSignIn (~> 6.0.1)
+ - Gridicons (~> 1.0)
+ - "NSURL+IDN (= 0.4)"
+ - SVProgressHUD (~> 2.2.5)
+ - WordPressKit (~> 8.0-beta)
+ - WordPressShared (~> 2.1-beta)
+ - WordPressUI (~> 1.7-beta)
+ - WordPressKit (8.0.0):
- Alamofire (~> 4.8.0)
- - CocoaLumberjack (~> 3.4)
- - NSObject-SafeExpectations (= 0.0.3)
- - UIDeviceIdentifier (~> 1)
- - WordPressShared (~> 1.8.13-beta)
- - wpxmlrpc (= 0.8.4)
- - WordPressMocks (0.0.8)
- - WordPressShared (1.8.15-beta.2):
- - CocoaLumberjack (~> 3.4)
- - FormatterKit/TimeIntervalFormatter (= 1.8.2)
- - WordPressUI (1.5.1)
- - WPMediaPicker (1.6.0)
- - wpxmlrpc (0.8.4)
+ - NSObject-SafeExpectations (~> 0.0.4)
+ - UIDeviceIdentifier (~> 2.0)
+ - WordPressShared (~> 2.0-beta)
+ - wpxmlrpc (~> 0.10)
+ - WordPressShared (2.2.0)
+ - WordPressUI (1.12.5)
+ - WPMediaPicker (1.8.7)
+ - wpxmlrpc (0.10.0)
- Yoga (1.14.0)
- - ZendeskCommonUISDK (4.0.0):
- - ZendeskSDKConfigurationsSDK (~> 1.1.2)
- - ZendeskCoreSDK (2.2.1)
- - ZendeskMessagingAPISDK (3.0.0):
- - ZendeskSDKConfigurationsSDK (~> 1.1.2)
- - ZendeskMessagingSDK (3.0.0):
- - ZendeskCommonUISDK (~> 4.0.0)
- - ZendeskMessagingAPISDK (~> 3.0.0)
- - ZendeskSDKConfigurationsSDK (1.1.2)
- - ZendeskSupportProvidersSDK (5.0.0):
- - ZendeskCoreSDK (~> 2.2.1)
- - ZendeskSupportSDK (5.0.0):
- - ZendeskMessagingSDK (~> 3.0.0)
- - ZendeskSupportProvidersSDK (~> 5.0.0)
- - ZIPFoundation (0.9.9)
+ - ZendeskCommonUISDK (6.1.2)
+ - ZendeskCoreSDK (2.5.1)
+ - ZendeskMessagingAPISDK (3.8.3):
+ - ZendeskSDKConfigurationsSDK (= 1.1.9)
+ - ZendeskMessagingSDK (3.8.3):
+ - ZendeskCommonUISDK (= 6.1.2)
+ - ZendeskMessagingAPISDK (= 3.8.3)
+ - ZendeskSDKConfigurationsSDK (1.1.9)
+ - ZendeskSupportProvidersSDK (5.3.0):
+ - ZendeskCoreSDK (~> 2.5.1)
+ - ZendeskSupportSDK (5.3.0):
+ - ZendeskMessagingSDK (~> 3.8.2)
+ - ZendeskSupportProvidersSDK (~> 5.3.0)
+ - ZIPFoundation (0.9.13)
DEPENDENCIES:
- - 1PasswordExtension (= 1.8.5)
- Alamofire (= 4.8.0)
+ - AlamofireImage (= 3.5.2)
- AlamofireNetworkActivityIndicator (~> 2.4)
- - AppCenter (= 2.5.1)
- - AppCenter/Distribute (= 2.5.1)
- - Automattic-Tracks-iOS (~> 0.4.3)
- - Charts (~> 3.2.2)
- - CocoaLumberjack (= 3.5.2)
+ - AppCenter (~> 4.1)
+ - AppCenter/Distribute (~> 4.1)
+ - Automattic-Tracks-iOS (~> 2.2)
+ - boost (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/boost.podspec.json`)
+ - BVLinearGradient (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/BVLinearGradient.podspec.json`)
+ - CocoaLumberjack/Swift (~> 3.0)
+ - CropViewController (= 2.5.3)
- Down (~> 0.6.6)
- - FBLazyVector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/FBLazyVector.podspec.json`)
- - FBReactNativeSpec (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/FBReactNativeSpec.podspec.json`)
- - Folly (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/Folly.podspec.json`)
- - FormatterKit/TimeIntervalFormatter (= 1.8.2)
+ - FBLazyVector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/FBLazyVector.podspec.json`)
+ - FBReactNativeSpec (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/FBReactNativeSpec/FBReactNativeSpec.podspec.json`)
- FSInteractiveMap (from `https://github.com/wordpress-mobile/FSInteractiveMap.git`, tag `0.2.0`)
- Gifu (= 3.2.0)
- - glog (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/glog.podspec.json`)
- - Gridicons (~> 0.16)
- - Gutenberg (from `http://github.com/wordpress-mobile/gutenberg-mobile/`, commit `d377b883c761c2a71d29bd631f3d3227b3e313a2`)
+ - glog (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/glog.podspec.json`)
+ - Gridicons (~> 1.1.0)
+ - Gutenberg (from `https://github.com/wordpress-mobile/gutenberg-mobile.git`, tag `v1.94.0`)
- JTAppleCalendar (~> 8.0.2)
- - MediaEditor (~> 1.0.1)
+ - Kanvas (~> 1.4.4)
+ - MediaEditor (~> 1.2.1)
- MRProgress (= 0.8.3)
- - Nimble (~> 7.3.1)
- - NSObject-SafeExpectations (= 0.0.3)
- - "NSURL+IDN (= 0.3)"
- - OCMock (= 3.4.3)
- - OHHTTPStubs (= 6.1.0)
- - OHHTTPStubs/Swift (= 6.1.0)
- - RCTRequired (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RCTRequired.podspec.json`)
- - RCTTypeSafety (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RCTTypeSafety.podspec.json`)
+ - NSObject-SafeExpectations (~> 0.0.4)
+ - "NSURL+IDN (~> 0.4)"
+ - OCMock (~> 3.4.3)
+ - OHHTTPStubs/Swift (~> 9.1.0)
+ - RCT-Folly (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCT-Folly.podspec.json`)
+ - RCTRequired (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCTRequired.podspec.json`)
+ - RCTTypeSafety (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCTTypeSafety.podspec.json`)
- Reachability (= 3.2)
- - React (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React.podspec.json`)
- - React-Core (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-Core.podspec.json`)
- - React-CoreModules (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-CoreModules.podspec.json`)
- - React-cxxreact (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-cxxreact.podspec.json`)
- - React-jsi (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsi.podspec.json`)
- - React-jsiexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsiexecutor.podspec.json`)
- - React-jsinspector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsinspector.podspec.json`)
- - react-native-keyboard-aware-scroll-view (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-keyboard-aware-scroll-view.podspec.json`)
- - react-native-linear-gradient (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-linear-gradient.podspec.json`)
- - react-native-safe-area (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-safe-area.podspec.json`)
- - react-native-slider (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-slider.podspec.json`)
- - react-native-video (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-video.podspec.json`)
- - React-RCTActionSheet (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTActionSheet.podspec.json`)
- - React-RCTAnimation (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTAnimation.podspec.json`)
- - React-RCTBlob (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTBlob.podspec.json`)
- - React-RCTImage (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTImage.podspec.json`)
- - React-RCTLinking (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTLinking.podspec.json`)
- - React-RCTNetwork (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTNetwork.podspec.json`)
- - React-RCTSettings (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTSettings.podspec.json`)
- - React-RCTText (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTText.podspec.json`)
- - React-RCTVibration (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTVibration.podspec.json`)
- - ReactCommon (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/ReactCommon.podspec.json`)
- - ReactNativeDarkMode (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/ReactNativeDarkMode.podspec.json`)
- - RNSVG (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RNSVG.podspec.json`)
- - RNTAztecView (from `http://github.com/wordpress-mobile/gutenberg-mobile/`, commit `d377b883c761c2a71d29bd631f3d3227b3e313a2`)
- - SimulatorStatusMagic
+ - React (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React.podspec.json`)
+ - React-bridging (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-bridging.podspec.json`)
+ - React-callinvoker (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-callinvoker.podspec.json`)
+ - React-Codegen (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-Codegen.podspec.json`)
+ - React-Core (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-Core.podspec.json`)
+ - React-CoreModules (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-CoreModules.podspec.json`)
+ - React-cxxreact (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-cxxreact.podspec.json`)
+ - React-jsi (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsi.podspec.json`)
+ - React-jsiexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsiexecutor.podspec.json`)
+ - React-jsinspector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsinspector.podspec.json`)
+ - React-logger (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-logger.podspec.json`)
+ - react-native-blur (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-blur.podspec.json`)
+ - react-native-get-random-values (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-get-random-values.podspec.json`)
+ - react-native-safe-area (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-safe-area.podspec.json`)
+ - react-native-safe-area-context (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-safe-area-context.podspec.json`)
+ - react-native-slider (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-slider.podspec.json`)
+ - react-native-video (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-video.podspec.json`)
+ - react-native-webview (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-webview.podspec.json`)
+ - React-perflogger (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-perflogger.podspec.json`)
+ - React-RCTActionSheet (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTActionSheet.podspec.json`)
+ - React-RCTAnimation (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTAnimation.podspec.json`)
+ - React-RCTBlob (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTBlob.podspec.json`)
+ - React-RCTImage (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTImage.podspec.json`)
+ - React-RCTLinking (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTLinking.podspec.json`)
+ - React-RCTNetwork (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTNetwork.podspec.json`)
+ - React-RCTSettings (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTSettings.podspec.json`)
+ - React-RCTText (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTText.podspec.json`)
+ - React-RCTVibration (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTVibration.podspec.json`)
+ - React-runtimeexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-runtimeexecutor.podspec.json`)
+ - ReactCommon (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/ReactCommon.podspec.json`)
+ - RNCClipboard (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNCClipboard.podspec.json`)
+ - RNCMaskedView (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNCMaskedView.podspec.json`)
+ - RNFastImage (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNFastImage.podspec.json`)
+ - RNGestureHandler (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNGestureHandler.podspec.json`)
+ - RNReanimated (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNReanimated.podspec.json`)
+ - RNScreens (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNScreens.podspec.json`)
+ - RNSVG (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNSVG.podspec.json`)
+ - RNTAztecView (from `https://github.com/wordpress-mobile/gutenberg-mobile.git`, tag `v1.94.0`)
- Starscream (= 3.0.6)
- SVProgressHUD (= 2.2.5)
- - WordPress-Editor-iOS (~> 1.16.0)
- - WordPressAuthenticator (~> 1.10.9-beta)
- - WordPressKit (~> 4.5.9-beta)
- - WordPressMocks (~> 0.0.8)
- - WordPressShared (= 1.8.15-beta.2)
- - WordPressUI (~> 1.5.1)
- - WPMediaPicker (~> 1.6.0)
- - Yoga (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/Yoga.podspec.json`)
- - ZendeskSupportSDK (= 5.0.0)
+ - SwiftLint (~> 0.50)
+ - WordPress-Editor-iOS (~> 1.19.8)
+ - WordPressAuthenticator (~> 6.1-beta)
+ - WordPressKit (~> 8.0-beta)
+ - WordPressShared (from `https://github.com/wordpress-mobile/WordPress-iOS-Shared.git`, branch `trunk`)
+ - WordPressUI (~> 1.12.5)
+ - WPMediaPicker (~> 1.8.7)
+ - Yoga (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/Yoga.podspec.json`)
+ - ZendeskSupportSDK (= 5.3.0)
- ZIPFoundation (~> 0.9.8)
SPEC REPOS:
+ https://github.com/wordpress-mobile/cocoapods-specs.git:
+ - WordPressAuthenticator
+ - WordPressKit
trunk:
- - 1PasswordExtension
- Alamofire
+ - AlamofireImage
- AlamofireNetworkActivityIndicator
+ - AppAuth
- AppCenter
- Automattic-Tracks-iOS
- - boost-for-react-native
- - Charts
- CocoaLumberjack
+ - CropViewController
- DoubleConversion
- Down
- - FormatterKit
+ - fmt
- Gifu
- GoogleSignIn
- - GoogleToolboxForMac
- Gridicons
+ - GTMAppAuth
- GTMSessionFetcher
- JTAppleCalendar
- - lottie-ios
+ - Kanvas
+ - libwebp
- MediaEditor
- MRProgress
- - Nimble
- NSObject-SafeExpectations
- "NSURL+IDN"
- OCMock
- OHHTTPStubs
- Reachability
+ - SDWebImage
+ - SDWebImageWebPCoder
- Sentry
- - SimulatorStatusMagic
+ - SentryPrivate
+ - Sodium
- Starscream
- SVProgressHUD
- - TOCropViewController
+ - SwiftLint
- UIDeviceIdentifier
- WordPress-Aztec-iOS
- WordPress-Editor-iOS
- - WordPressAuthenticator
- - WordPressKit
- - WordPressMocks
- - WordPressShared
- WordPressUI
- WPMediaPicker
- wpxmlrpc
@@ -538,174 +675,231 @@ SPEC REPOS:
- ZIPFoundation
EXTERNAL SOURCES:
+ boost:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/boost.podspec.json
+ BVLinearGradient:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/BVLinearGradient.podspec.json
FBLazyVector:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/FBLazyVector.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/FBLazyVector.podspec.json
FBReactNativeSpec:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/FBReactNativeSpec.podspec.json
- Folly:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/Folly.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/FBReactNativeSpec/FBReactNativeSpec.podspec.json
FSInteractiveMap:
:git: https://github.com/wordpress-mobile/FSInteractiveMap.git
:tag: 0.2.0
glog:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/glog.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/glog.podspec.json
Gutenberg:
- :commit: d377b883c761c2a71d29bd631f3d3227b3e313a2
- :git: http://github.com/wordpress-mobile/gutenberg-mobile/
+ :git: https://github.com/wordpress-mobile/gutenberg-mobile.git
+ :submodules: true
+ :tag: v1.94.0
+ RCT-Folly:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCT-Folly.podspec.json
RCTRequired:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RCTRequired.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCTRequired.podspec.json
RCTTypeSafety:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RCTTypeSafety.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCTTypeSafety.podspec.json
React:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React.podspec.json
+ React-bridging:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-bridging.podspec.json
+ React-callinvoker:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-callinvoker.podspec.json
+ React-Codegen:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-Codegen.podspec.json
React-Core:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-Core.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-Core.podspec.json
React-CoreModules:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-CoreModules.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-CoreModules.podspec.json
React-cxxreact:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-cxxreact.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-cxxreact.podspec.json
React-jsi:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsi.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsi.podspec.json
React-jsiexecutor:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsiexecutor.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsiexecutor.podspec.json
React-jsinspector:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsinspector.podspec.json
- react-native-keyboard-aware-scroll-view:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-keyboard-aware-scroll-view.podspec.json
- react-native-linear-gradient:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-linear-gradient.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsinspector.podspec.json
+ React-logger:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-logger.podspec.json
+ react-native-blur:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-blur.podspec.json
+ react-native-get-random-values:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-get-random-values.podspec.json
react-native-safe-area:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-safe-area.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-safe-area.podspec.json
+ react-native-safe-area-context:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-safe-area-context.podspec.json
react-native-slider:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-slider.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-slider.podspec.json
react-native-video:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-video.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-video.podspec.json
+ react-native-webview:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-webview.podspec.json
+ React-perflogger:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-perflogger.podspec.json
React-RCTActionSheet:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTActionSheet.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTActionSheet.podspec.json
React-RCTAnimation:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTAnimation.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTAnimation.podspec.json
React-RCTBlob:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTBlob.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTBlob.podspec.json
React-RCTImage:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTImage.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTImage.podspec.json
React-RCTLinking:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTLinking.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTLinking.podspec.json
React-RCTNetwork:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTNetwork.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTNetwork.podspec.json
React-RCTSettings:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTSettings.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTSettings.podspec.json
React-RCTText:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTText.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTText.podspec.json
React-RCTVibration:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTVibration.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTVibration.podspec.json
+ React-runtimeexecutor:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-runtimeexecutor.podspec.json
ReactCommon:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/ReactCommon.podspec.json
- ReactNativeDarkMode:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/ReactNativeDarkMode.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/ReactCommon.podspec.json
+ RNCClipboard:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNCClipboard.podspec.json
+ RNCMaskedView:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNCMaskedView.podspec.json
+ RNFastImage:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNFastImage.podspec.json
+ RNGestureHandler:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNGestureHandler.podspec.json
+ RNReanimated:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNReanimated.podspec.json
+ RNScreens:
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNScreens.podspec.json
RNSVG:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RNSVG.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNSVG.podspec.json
RNTAztecView:
- :commit: d377b883c761c2a71d29bd631f3d3227b3e313a2
- :git: http://github.com/wordpress-mobile/gutenberg-mobile/
+ :git: https://github.com/wordpress-mobile/gutenberg-mobile.git
+ :submodules: true
+ :tag: v1.94.0
+ WordPressShared:
+ :branch: trunk
+ :git: https://github.com/wordpress-mobile/WordPress-iOS-Shared.git
Yoga:
- :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/Yoga.podspec.json
+ :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/Yoga.podspec.json
CHECKOUT OPTIONS:
FSInteractiveMap:
:git: https://github.com/wordpress-mobile/FSInteractiveMap.git
:tag: 0.2.0
Gutenberg:
- :commit: d377b883c761c2a71d29bd631f3d3227b3e313a2
- :git: http://github.com/wordpress-mobile/gutenberg-mobile/
+ :git: https://github.com/wordpress-mobile/gutenberg-mobile.git
+ :submodules: true
+ :tag: v1.94.0
RNTAztecView:
- :commit: d377b883c761c2a71d29bd631f3d3227b3e313a2
- :git: http://github.com/wordpress-mobile/gutenberg-mobile/
+ :git: https://github.com/wordpress-mobile/gutenberg-mobile.git
+ :submodules: true
+ :tag: v1.94.0
+ WordPressShared:
+ :commit: 9a010fdab8d31f9e1fa0511f231e7068ef0170b1
+ :git: https://github.com/wordpress-mobile/WordPress-iOS-Shared.git
SPEC CHECKSUMS:
- 1PasswordExtension: 0e95bdea64ec8ff2f4f693be5467a09fac42a83d
Alamofire: 3ec537f71edc9804815215393ae2b1a8ea33a844
+ AlamofireImage: 63cfe3baf1370be6c498149687cf6db3e3b00999
AlamofireNetworkActivityIndicator: 9acc3de3ca6645bf0efed462396b0df13dd3e7b8
- AppCenter: fddcbac6e4baae3d93a196ceb0bfe0e4ce407dec
- Automattic-Tracks-iOS: 5515b3e6a5e55183a244ca6cb013df26810fa994
- boost-for-react-native: 39c7adb57c4e60d6c5479dd8623128eb5b3f0f2c
- Charts: f69cf0518b6d1d62608ca504248f1bbe0b6ae77e
- CocoaLumberjack: 118bf4a820efc641f79fa487b75ed928dccfae23
+ AppAuth: e48b432bb4ba88b10cb2bcc50d7f3af21e78b9c2
+ AppCenter: b0b6f1190215b5f983c42934db718f3b46fff3c0
+ Automattic-Tracks-iOS: a1b020ab02f0e5a39c5d4e6870a498273f286158
+ boost: 32a63928ef0a5bf8b60f6b930c8864113fa28779
+ BVLinearGradient: 708898fab8f7113d927b0ef611a321e759f6ad3e
+ CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732
+ CropViewController: a5c143548a0fabcd6cc25f2d26e40460cfb8c78c
DoubleConversion: e22e0762848812a87afd67ffda3998d9ef29170c
Down: 71bf4af3c04fa093e65dffa25c4b64fa61287373
- FBLazyVector: 47798d43f20e85af0d3cef09928b6e2d16dbbe4c
- FBReactNativeSpec: 8d0bf8eca089153f4196975ca190cda8c2d5dbd2
- Folly: 30e7936e1c45c08d884aa59369ed951a8e68cf51
- FormatterKit: 4b8f29acc9b872d5d12a63efb560661e8f2e1b98
+ FBLazyVector: 16fdf30fcbc7177c6a4bdf35ef47225577eb9636
+ FBReactNativeSpec: 2ffeca5f498ddc94234d823f38abf51ce0313171
+ fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
FSInteractiveMap: a396f610f48b76cb540baa87139d056429abda86
Gifu: 7bcb6427457d85e0b4dff5a84ec5947ac19a93ea
- glog: 1f3da668190260b06b429bb211bfbee5cd790c28
- GoogleSignIn: 7ff245e1a7b26d379099d3243a562f5747e23d39
- GoogleToolboxForMac: 800648f8b3127618c1b59c7f97684427630c5ea3
- Gridicons: dc92efbe5fd60111d2e8ea051d84a60cca552abc
- GTMSessionFetcher: cea130bbfe5a7edc8d06d3f0d17288c32ffe9925
- Gutenberg: fd94d54ccf8605564288cc6ef0f762da70f18b01
- JTAppleCalendar: bb3dd3752e2bcc85cb798ab763fbdd6e142715fc
- lottie-ios: 3fef45d3fabe63e3c7c2eb603dd64ddfffc73062
- MediaEditor: 7296cd01d7a0548fb2bc909aa72153b376a56a61
+ glog: 741689bdd65551bc8fb59d633e55c34293030d3e
+ GoogleSignIn: fd381840dbe7c1137aa6dc30849a5c3e070c034a
+ Gridicons: 17d660b97ce4231d582101b02f8280628b141c9a
+ GTMAppAuth: 0ff230db599948a9ad7470ca667337803b3fc4dd
+ GTMSessionFetcher: 5595ec75acf5be50814f81e9189490412bad82ba
+ Gutenberg: f0bc3334e1a5e81077ef2496536b097eaeafe93e
+ JTAppleCalendar: 932cadea40b1051beab10f67843451d48ba16c99
+ Kanvas: f932eaed3d3f47aae8aafb6c2d27c968bdd49030
+ libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
+ MediaEditor: 20cdeb46bdecd040b8bc94467ac85a52b53b193a
MRProgress: 16de7cc9f347e8846797a770db102a323fe7ef09
- Nimble: 051e3d8912d40138fa5591c78594f95fb172af37
- NSObject-SafeExpectations: b989b68a8a9b7b9f2b264a8b52ba9d7aab8f3129
- "NSURL+IDN": 82355a0afd532fe1de08f6417c134b49b1a1c4b3
+ NSObject-SafeExpectations: ab8fe623d36b25aa1f150affa324e40a2f3c0374
+ "NSURL+IDN": afc873e639c18138a1589697c3add197fe8679ca
OCMock: 43565190abc78977ad44a61c0d20d7f0784d35ab
- OHHTTPStubs: 1e21c7d2c084b8153fc53d48400d8919d2d432d0
- RCTRequired: 3ca691422140f76f04fd2af6dc90914cf0f81ef1
- RCTTypeSafety: aab4e9679dbb3682bf0404fded7b9557d7306795
+ OHHTTPStubs: 90eac6d8f2c18317baeca36698523dc67c513831
+ RCT-Folly: b60af04f04d86a9f9c3317ba253365c4bd30ac5f
+ RCTRequired: f29d295ee209e2ac38b0aede22af2079ba814983
+ RCTTypeSafety: 385273055103e9b60ac9ec070900621d3a31ff28
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
- React: 5a954890216a4493df5ab2149f70f18592b513ac
- React-Core: 865fa241faa644ff20cb5ec87787b32a5acc43b3
- React-CoreModules: 026fafece67a3802aa8bb1995d27227b0d95e0f5
- React-cxxreact: 9c76312456310d1b486e23edb9ce576a5397ebc2
- React-jsi: 6d6afac4873e8a3433334378589a0a8190d58070
- React-jsiexecutor: 9dfdcd0db23042623894dcbc02d61a772da8e3c1
- React-jsinspector: 89927b9ec6d75759882949d2043ba704565edaec
- react-native-keyboard-aware-scroll-view: 01c4b2303c4ef1c49c4d239c9c5856f0393104df
- react-native-linear-gradient: 258ba8c61848324b1f2019bed5f460e6396137b7
- react-native-safe-area: e8230b0017d76c00de6b01e2412dcf86b127c6a3
- react-native-slider: b36527edad24d49d9f3b53f3078334f45558f97b
- react-native-video: 9de661e89386bb7ab78cc68e61a146cbdf5ad4ad
- React-RCTActionSheet: e8f642cfaa396b6b09fd38f53378506c2d63af35
- React-RCTAnimation: cec1abbcfb006978a288c5072e3d611d6ff76d4c
- React-RCTBlob: 7596eb2048150e429127a92a701e6cd40a8c0a74
- React-RCTImage: 03c7e36877a579ee51dcc33079cc8bc98658a722
- React-RCTLinking: cdc3f1aaff5f321bc954a98b7ffae3f864a6eaa3
- React-RCTNetwork: 33b3da6944786edea496a5fc6afea466633fd711
- React-RCTSettings: a3b7b3124315f8c91fad5d8aff08ee97d4b471cd
- React-RCTText: ee9c8b70180fb58d062483d9664cd921d14b5961
- React-RCTVibration: 20deb1f6f001000d1f2603722ec110c66c74796b
- ReactCommon: 48926fc48fcd7c8a629860049ffba9c23b4005dc
- ReactNativeDarkMode: f61376360c5d983907e5c316e8e1c853a8c2f348
- RNSVG: 68a534a5db06dcbdaebfd5079349191598caef7b
- RNTAztecView: 48948d6a92e3202dca86fbb3c579b0b3065c89fd
- Sentry: 14bdd673870e8cf64932b149fad5bbbf39a9b390
- SimulatorStatusMagic: 28d4a9d1a500ac7cea0b2b5a43c1c6ddb40ba56c
+ React: ee95447578c5b9789ba7aad0593d162b72a45e6f
+ React-bridging: 011e313a56cbb8e98f97749b83f4b43fafdcf3db
+ React-callinvoker: 132da8333bd1a22a4d637a800bcd5e9bb051404f
+ React-Codegen: 1bb3fbcd85a52638967113eab1cc0acb3e719c6f
+ React-Core: bd57dad64f256ac856c5a5341c3433593bc9e98b
+ React-CoreModules: 98d0fd895946722aeda6214ff155f0ddeef02fa3
+ React-cxxreact: 53614bcfdacdf57c6bf5ebbeb942dd020f6c9f37
+ React-jsi: 828954dea2cd2fba7433d1c2e824d26f4a1c09fd
+ React-jsiexecutor: 8dfd84cc30ef554c37084f040db8171f998bec6c
+ React-jsinspector: f86975c8251bd7882f9a9d68df150db287a822bb
+ React-logger: 16a67709f5aa1d752fd09f9e6ccbf802ba0c24e9
+ react-native-blur: 14c75aa19da8734c1656d5b6ca5adb859b2c26aa
+ react-native-get-random-values: 2869478c635a6e33080b917ce33f2803cb69262c
+ react-native-safe-area: e3de9e959c7baaae8db9bcb015d99ed1da25c9d5
+ react-native-safe-area-context: 1e501ec30c260422def56e95e11edb103caa5bf2
+ react-native-slider: f1ea4381d6d43ef5b945b5b101e9c66d249630a6
+ react-native-video: 7b1832a8dcea07303f5e696b639354ea599931ff
+ react-native-webview: fca2337b045c6554b4209ab5073e922fabac8e17
+ React-perflogger: 685c7bd5da242acbe09ae37488dd81c7d41afbb4
+ React-RCTActionSheet: 6c194ed0520d57075d03f3daf58ad025b1fb98a2
+ React-RCTAnimation: 2c9468ff7d0116801a994f445108f4be4f41f9df
+ React-RCTBlob: 18a19196ddf511eaab0be1ff30feb0c38b9ad5c9
+ React-RCTImage: 72af5e51c5ce2e725ebddea590901fb9c4fd46aa
+ React-RCTLinking: 6224cf6652cb9a6304c7d5c3e5ab92a72a0c9bf7
+ React-RCTNetwork: e82a24ca24d461dd8f9c087eb4332bd77004c906
+ React-RCTSettings: 81df0a79a648cb1678220e926d92e6ebc5ea6cc5
+ React-RCTText: b55360e76043f19128eee6ac04e0cbd53e6baf79
+ React-RCTVibration: 87d2dbefada4a1c345dcdc4c522494ac95c8bc9a
+ React-runtimeexecutor: f4e1071b6cebeef4a30896343960606dc09ca079
+ ReactCommon: bb76a4ca9fb5c2b8b1428dcfe0bc32eba5e4c02d
+ RNCClipboard: e2298216e12d730c3c2eb9484095e1f2e1679cce
+ RNCMaskedView: b467479e450f13e5dcee04423fefd2534f08c3eb
+ RNFastImage: 9407b5abc43452149a2f628107c64a7d11aa2948
+ RNGestureHandler: f1645f845fc899a01cd7f87edf634b670de91b07
+ RNReanimated: 8abe8173f54110a9ae98a629d0d8bf343a84f739
+ RNScreens: bd1f43d7dfcd435bc11d4ee5c60086717c45a113
+ RNSVG: 259ef12cbec2591a45fc7c5f09d7aa09e6692533
+ RNTAztecView: 80480c43423929f7e3b7012670787e7375fbac9c
+ SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
+ SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
+ Sentry: d80553ff85ea72def75b792aaa5a71c158e51595
+ SentryPrivate: ef1c5b3dfe44ec0c70e2eb343a5be2689164c021
+ Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da
Starscream: ef3ece99d765eeccb67de105bfa143f929026cf5
SVProgressHUD: 1428aafac632c1f86f62aa4243ec12008d7a51d6
- TOCropViewController: e9da34f484aedd4e5d5a8ab230ba217cfe16c729
- UIDeviceIdentifier: 44f805037d21b94394821828f4fcaba34b38c2d0
- WordPress-Aztec-iOS: 64a2989d25befb5ce086fac440315f696026ffd5
- WordPress-Editor-iOS: 63ef6a532af2c92e3301421f5c4af41ad3be8721
- WordPressAuthenticator: 64239e90c2bb2b1885789da6510575744674a65d
- WordPressKit: 54b1c041c59b871e91a331f24a2fb5d347e070b0
- WordPressMocks: b4064b99a073117bbc304abe82df78f2fbe60992
- WordPressShared: 28f28c072d5d97fbd892fa23d58f4205c2e09e90
- WordPressUI: ce0ac522146dabcd0a68ace24c0104dfdf6f4b0d
- WPMediaPicker: e5d28197da6b467d4e5975d64a49255977e39455
- wpxmlrpc: 6ba55c773cfa27083ae4a2173e69b19f46da98e2
- Yoga: c920bf12bf8146aa5cd118063378c2cf5682d16c
- ZendeskCommonUISDK: 3c432801e31abff97d6e30441ea102eaef6b99e2
- ZendeskCoreSDK: f264e849b941a4b9b22215520765b8d9980478c3
- ZendeskMessagingAPISDK: 7c0cbd1d2c941f05b36f73e7db5faee5863fe8b0
- ZendeskMessagingSDK: 6f168161d834dd66668344f645f7a6b6b121b58a
- ZendeskSDKConfigurationsSDK: 13eaf9b688504aaf7d5803c33772ced314b2e837
- ZendeskSupportProvidersSDK: 96b704d58bf0d44978de135607059f379c766e58
- ZendeskSupportSDK: a87ab1e4badace92c75eb11dc77ede1e995b2adc
- ZIPFoundation: 89df685c971926b0323087952320bdfee9f0b6ef
+ SwiftLint: 77f7cb2b9bb81ab4a12fcc86448ba3f11afa50c6
+ UIDeviceIdentifier: 442b65b4ff1832d4ca9c2a157815cb29ad981b17
+ WordPress-Aztec-iOS: 7d11d598f14c82c727c08b56bd35fbeb7dafb504
+ WordPress-Editor-iOS: 9eb9f12f21a5209cb837908d81ffe1e31cb27345
+ WordPressAuthenticator: b0b900696de5129a215adcd1e9ae6eb89da36ac8
+ WordPressKit: b65a51863982d8166897bea8b753f1fc51732aad
+ WordPressShared: 87f3ee89b0a3e83106106f13a8b71605fb8eb6d2
+ WordPressUI: c5be816f6c7b3392224ac21de9e521e89fa108ac
+ WPMediaPicker: 0d45dfd7b3c5651c5236ffd48c1b0b2f60a2d5d2
+ wpxmlrpc: 68db063041e85d186db21f674adf08d9c70627fd
+ Yoga: 5e12f4deb20582f86f6323e1cdff25f07afc87f6
+ ZendeskCommonUISDK: 5f0a83f412e07ae23701f18c412fe783b3249ef5
+ ZendeskCoreSDK: 19a18e5ef2edcb18f4dbc0ea0d12bd31f515712a
+ ZendeskMessagingAPISDK: db91be0c5cb88229d22f0e560ed99ba6e1dce02e
+ ZendeskMessagingSDK: ce2750c0a3dbd40918ea2e2d44dd0dbe34d21bc8
+ ZendeskSDKConfigurationsSDK: f91f54f3b41aa36ffbc43a37af9956752a062055
+ ZendeskSupportProvidersSDK: 2bdf8544f7cd0fd4c002546f5704b813845beb2a
+ ZendeskSupportSDK: 3a8e508ab1d9dd22dc038df6c694466414e037ba
+ ZIPFoundation: ae5b4b813d216d3bf0a148773267fff14bd51d37
-PODFILE CHECKSUM: 9405bf223f24480ab74f14e9cb3eec6313182ab4
+PODFILE CHECKSUM: ab88bd849ac377484fd7f0c4b079701ce16de5a3
-COCOAPODS: 1.8.4
+COCOAPODS: 1.11.3
diff --git a/README.md b/README.md
index 4cc2658b9ac9..94abc77b03bb 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,48 @@
# WordPress for iOS #
-[![CircleCI](https://circleci.com/gh/wordpress-mobile/WordPress-iOS.svg?style=svg)](https://circleci.com/gh/wordpress-mobile/WordPress-iOS)
+[![Build status](https://badge.buildkite.com/2f3fbb17bfbb5bba508efd80f1ea8d640db5ca2465a516a457.svg)](https://buildkite.com/automattic/wordpress-ios)
[![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com)
## Build Instructions
Please refer to the sections below for more detailed information. The instructions assume the work is performed from a command line.
+> Please note – these setup instructions only apply to Intel-based machines. M1-based Mac support is coming, but isn't yet supported by our tooling.
+
+### Getting Started
+
1. [Download](https://developer.apple.com/downloads/index.action) and install Xcode. *WordPress for iOS* requires Xcode 11.2.1 or newer.
-1. From a command line, `git clone git@github.com:wordpress-mobile/WordPress-iOS.git` in the folder of your preference.
-1. `cd WordPress-iOS` to enter the working directory.
-1. `rake dependencies` to install all dependencies required to run the project (this may take some time to complete).
-1. `rake xcode` to open the project in Xcode.
-1. Compile and run the app on a device or an simulator.
+1. From a command line, run `git clone git@github.com:wordpress-mobile/WordPress-iOS.git` in the folder of your preference.
+1. Now, run `cd WordPress-iOS` to enter the working directory.
-In order to login to WordPress.com using the app:
+#### Create WordPress.com API Credentials
1. Create a WordPress.com account at https://wordpress.com/start/user (if you don't already have one).
1. Create an application at https://developer.wordpress.com/apps/.
1. Set "Redirect URLs"= `https://localhost` and "Type" = `Native` and click "Create" then "Update".
-1. Copy the `Client ID` and `Client Secret` from the OAuth Information.
-1. From a command line, ensure you are in the project's working directory and run `cp WordPress/Credentials/wpcom_app_credentials-example .configure-files/wpcom_app_credentials` to copy the sample credentials file.
-1. Open the newly copied `.configure-files/wpcom_app_credentials` with the text editor of your choice, and replace `WPCOM_APP_ID` and `WPCOM_APP_SECRET` with the `Client ID` and `Client Secret` of the application you created. Note that `.configure-files` will be hidden by default in Finder. If you need to view it in Finder, hold down `Control`+`Shift`+`.` and it should appear.
-1. Recompile and run the app on a device or an simulator.
+1. Copy the `Client ID` and `Client Secret` from the OAuth Information.
+
+#### Configure Your WordPress App Development Environment
+
+1. Check that your local version of Ruby matches the one in [.ruby-version](./.ruby-version). We recommend installing a tool like [rbenv](https://github.com/rbenv/rbenv) so your system will always use the version defined in that file. Once installed, simply run `rbenv install` in the repo to match the version.
+1. Return to the command line and run `rake init:oss` to configure your computer and WordPress app to be able to run and login to WordPress.com
+1. Once completed, run `rake xcode` to open the project in Xcode.
+
+If all went well you can now compile to your iOS device or simulator, and log into the WordPress app.
-You can only log in with the WordPress.com account that you used to create the WordPress application.
+Note: You can only log in with the WordPress.com account that you used to create the WordPress application.
+
+## Configuration Details
+
+The steps above will help you configure the WordPress app to run and compile. But you may sometimes need to update or re-run specific parts of the initial setup (like updating the dependencies.) To see how to do that, please check out the steps below.
### Third party tools
-We use a few tools to help with development. Running `rake dependencies` will configure them for you.
+We use a few tools to help with development. Running `rake dependencies` will configure or update them for you.
#### CocoaPods
-WordPress for iOS uses [CocoaPods](http://cocoapods.org/) to manage third party libraries.
+WordPress for iOS uses [CocoaPods](http://cocoapods.org/) to manage third party libraries.
Third party libraries and resources managed by CocoaPods will be installed by the `rake dependencies` command above.
#### SwiftLint
@@ -63,7 +73,7 @@ Launch the workspace by running the following from the command line:
`rake xcode`
-This will ensure any dependencies are ready before launching Xcode.
+This will ensure any dependencies are ready before launching Xcode.
You can also open the project by double clicking on `WordPress.xcworkspace` file, or launching Xcode and choose `File` > `Open` and browse to `WordPress.xcworkspace`.
@@ -73,19 +83,14 @@ In order to login to WordPress.com with the app you need to create an account ov
After you create an account you can create an application on the [WordPress.com applications manager](https://developer.wordpress.com/apps/).
-When creating your application, select "Native client" for the application type. The applications manager currently requires a "redirect URL", but this isn't used for mobile apps. Just use "https://localhost".
-
-Your new application will have an associated client ID and a client secret key. These are used to authenticate the API calls made by your application.
-
-Next, create a credential file. Start by copying the sample credentials file in your local repo by doing this:
-
-`cp WordPress/Credentials/wpcom_app_credentials-example .configure-files/wpcom_app_credentials`
+When creating your application, you should select "Native client" for the application type.
+The "**Website URL**", "**Redirect URLs**", and "**Javascript Origins**" fields are required but not used for the mobile apps. Just use `https://localhost`.
-Then edit the `WordPress/Credentials/wpcom_app_credentials-example` file and change the `WPCOM_APP_ID` and `WPCOM_APP_SECRET` fields to the values of your application's client ID and client secret.
+Your new application will have an associated client ID and a client secret key. These are used to authenticate the API calls made by your application.
-Now you can compile and run the app on a simulator and log in with a WordPress.com account. Note that authenticating to WordPress.com via Google is not supported in development builds of the app, only in the official release.
+Next, run the command `rake credentials:setup` you will be prompted for your Client ID and your Client Secret. Once added you will be able to log into the WordPress app
-**Remember the only WordPress.com account you will be able to login in with is the one used to create your client ID and client secret.**
+**Remember the only WordPress.com account you will be able to login in with is the one used to create your client ID and client secret.**
Read more about [OAuth2](https://developer.wordpress.com/docs/oauth2/) and the [WordPress.com REST endpoint](https://developer.wordpress.com/docs/api/).
diff --git a/RELEASE-CYCLE.md b/RELEASE-CYCLE.md
deleted file mode 100644
index 2ac342a1da66..000000000000
--- a/RELEASE-CYCLE.md
+++ /dev/null
@@ -1,36 +0,0 @@
-WordPress iOS releases are handled following the [Git Flow](http://nvie.com/posts/a-successful-git-branching-model/) model for Git with most release cycles lasting 2 weeks.
-
-## Standard Release
-
-A description of what happens during a standard release taking as an example version `9.1` of the app.
-
-### Day 1 (Monday): CODE FREEZE
-
-- Create a new branch from develop called `release/9.1`: only features completed before Day 1 will make it to the release.
-- Generate the English strings file on this branch, this will pick up all the new strings that were added since the last release.
-- Mark the milestone as frozen.
-- Protect the branch to avoid unwanted merges.
-- Release the beta version and post the call for testing on [Make WordPress Mobile](https://make.wordpress.org/mobile/).
-- Merge back to develop.
-- A script will automatically pick up new strings and upload them to GlotPress for translation.
-
-### Day 2-13: STABILIZATION
-
-- If we discover any bugs on `release/9.1` that were introduced on the last sprint, important crashes, or bugs in new features to be released, we submit a PR targeting `release/9.1`, and we make a new beta release. We then merge back to develop.
-
-### Day 14: SUBMISSION & RELEASE
-
-- Fetch the localized strings from GlotPress and integrate them into the project.
-- Generate a production build and upload it to the store and phase release it.
-- Finalize the release on GitHub and close the milestone.
-- Merge `release/9.1` into `develop` and into `master`.
-
-## Hot Fix
-
-Sometimes there is a bug or crash that can’t wait two weeks to be fixed. This is how we handle this, for example when a critical issue is uncovered on version `9.1` of the app, currently released.
-
-- Create a new branch from master called `release/9.1.1`.
-- Create a PR against that branch.
-- Get approvals, test very very very well, merge.
-- Submit to the store.
-- Merge back into `develop` and into `master`.
diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index d6f4156fe8e2..316f2216ff84 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -1,7 +1,948 @@
+22.4
+-----
+* [**] [Jetpack-only] Adds a dashboard card for viewing activity log. [#20569]
+* [**] [Jetpack-only] Adds a dashboard card for viewing pages. [#20524]
+
+22.3
+-----
+* [*] [internal] Allow updating specific fields when updating media details. [#20606]
+* [**] Block Editor: Enable VideoPress block (only on Simple WPCOM sites) [#20580]
+
+22.2
+-----
+* [**] [Jetpack-only] Added a dashboard card for purchasing domains. [#20424]
+* [*] [internal] [Jetpack-only] Redesigned the migration success card. [#20515]
+* [**] [internal] Refactored Google SignIn implementation to not use the Google SDK [#20128]
+* [***] Block Editor: Resolved scroll-jump issues and enhanced caret focus management [https://github.com/WordPress/gutenberg/pull/48791]
+* [**] [Jetpack-only] Blogging Prompts: adds the ability to view other users' responses to a prompt. [#20540]
+
+22.1
+-----
+* [**] [internal] Refactor updating account related Core Data operations, which ususally happens during log in and out of the app. [#20394]
+* [***] [internal] Refactor uploading photos (from the device photo, the Free Photo library, and other sources) to the WordPress Media Library. Affected areas are where you can choose a photo and upload, including the "Media" screen, adding images to a post, updating site icon, etc. [#20322]
+* [**] [WordPress-only] Warns user about sites with only individual plugins not supporting core app features and offers the option to switch to the Jetpack app. [#20408]
+* [*] [Reader] Fix an issue that was causing the app to crash when tapping the More or Share buttons in Reader Detail screen. [#20490]
+* [*] Block editor: Avoid empty Gallery block error [https://github.com/WordPress/gutenberg/pull/49557]
+
+22.0
+-----
+* [*] Remove large title in Reader and Notifications tabs. [#20271]
+* [*] Reader: Change the following button cog icon. [#20274]
+* [*] [Jetpack-only] Change the dark background color of toolbars and top tabs across the whole app. [#20278]
+* [*] Change the Reader's navigation bar background color to match other screens. [#20278]
+* [*] Tweak My Site Dashboard Cards UI. [#20303]
+* [*] [Jetpack-only] Change My Sites tab bar icon. [#20310]
+* [*] [internal] Refactored the Core Data operations (saving the site data) after a new site is created. [#20270]
+* [*] [internal] Refactored updating user role in the "People" screen on the "My Sites" tab. [#20244]
+* [*] [internal] Refactor managing social connections and social buttons in the "Sharing" screen. [#20265]
+* [*] [internal] Refactor uploading media assets. [#20294]
+* [*] Block editor: Allow new block transforms for most blocks. [https://github.com/WordPress/gutenberg/pull/48792]
+* [*] Visual improvements were made to the in-app survey along with updated text to differentiate between the WordPress and Jetpack apps. [#20276]
+* [*] Reader: Resolve an issue that could cause the app to crash when blocking a post author. [#20421]
+
+21.9
+-----
+* [*] [internal] Refactored fetching posts in the Reader tab, including post related operations (i.e. like/unlike, save for later, etc.) [#20197]
+* [**] Reader: Add a button in the post menu to block an author and stop seeing their posts. [#20193]
+* [**] [Jetpack-only] Jetpack individual plugin support: Warns user about sites with only individual plugins not supporting all features of the app yet and gives the ability to install the full Jetpack plugin. [#20223]
+* [**] [Jetpack-only] Help: Display the Jetpack app FAQ card on Help screen when switching from the WordPress app to the Jetpack app is complete. [#20232]
+* [***] [Jetpack-only] Blaze: We added support for Blaze in the app. The user can now promote a post or page from the app to reach new audiences. [#20253]
+
+21.8.1
+-----
+* [**] [internal] Fixes a crash that happens in the background when the weekly roundup notification is being processed. [#20275]
+
+21.8
+-----
+* [*] [WordPress-only] We have redesigned and simplified the landing screen. [#20061]
+* [*] [internal] Refactored account related operations (i.e. log in and out of the app). [#19893]
+* [*] [internal] Refactored comment related operations (i.e. like a comment, reply to a post or comment).
+* [*] [internal] Refactored how reader topics are fetched from the database. [#20129]
+* [*] [internal] Refactored blog related operations (i.e. loading blogs of the logged in account, updating blog settings). [#20047]
+* [*] Reader: Add ability to block a followed site. [#20053]
+* [*] Reader: Add ability to report a post's author. [#20064]
+* [*] [internal] Refactored the topic related features in the Reader tab (i.e. following, unfollowing, and search). [#20150]
+* [*] Fix inaccessible block settings within the unsupported block editor [https://github.com/WordPress/gutenberg/pull/48435]
+
+21.7
+-----
+* [*] [Jetpack-only] Fixed an issue where stats were not displaying latest data when the system date rolls over to the next day while the app is in background. [#19989]
+* [*] [Jetpack-only] Hide Scan Login Code when logged into an account with 2FA. [#19567]
+* [**] [Jetpack-only] Blogging Prompts: add the ability to answer previous prompts, disable prompts, and other minor enhancements. [#20055]
+
+21.6
+-----
+* [*] Fix a layout issue impacting the "No media matching your search" empty state message of the Media Picker screen. [#19820]
+* [**] [internal] Refactor saving changes in the "Account Settings" page. [#19910]
+* [*] The Migration flow doesn't complete automatically if the user interrupts the migration mid flow. [#19888]
+* [**] [internal] Refactored fetching blog editor settings. [#19915]
+* [*] [Jetpack-only] The Migration flow doesn't complete automatically if the user interrupts the migration mid flow. [#19888]
+* [***] [Jetpack-only] Stats Insights Update. Helps you understand how your content is performing and what’s resonating with your audience. [#19909]
+* [***] [internal] Delete all the activity logs after logging out. [#19930]
+* [*] [Jetpack-only] Fixed an issue where Stats Followers details did not update on Pull-to-refresh in the Stats Followers Details screen [#19935]
+* [**] Refactored loading WP.com plans. [#19949]
+* [*] Resolve an edge case that was causing the user to be stuck in the "Onboading Questions" screen. [#19791]
+* [*] [Jetpack-only] Tweak Migration Screens UI when fonts are enlarged. [#19944]
+
+21.5.1
+-----
+* [*] [Jetpack-only] Fixed a bug where the Login flow was restarting every time the app enters the foreground. [#19961]
+
+21.5
+-----
+* [***] [internal] A significant refactor to the app’s architecture was made to allow for the new simplified UI. Regression testing on the app’s main flows is needed. [#19817]
+* [**] [internal] Disable Story posts when Jetpack features are removed [#19823]
+* [*] [internal] Editor: Only register core blocks when `onlyCoreBlocks` capability is enabled [https://github.com/wordpress-mobile/gutenberg-mobile/pull/5293]
+* [**] [internal] Disable StockPhoto and Tenor media sources when Jetpack features are removed [#19826]
+* [*] [Jetpack-only] Fixed a bug where analytics calls weren't synced to the user account. [#19926]
+
+21.4
+-----
+* [*] Fixed an issue where publishing Posts and Pages could fail under certain conditions. [#19717]
+* [*] Share extension navigation bar is no longer transparent [#19700]
+* [***] [Jetpack-only] Adds a smooth, opt-in transition to the Jetpack app for users migrating from the WordPress app. [#19759]
+* [***] You can now migrate your site content to the Jetpack app without a hitch. [#19759]
+* [**] [internal] Upgrade React Native from 0.66.2 to 0.69.4 [https://github.com/wordpress-mobile/gutenberg-mobile/pull/5193]
+* [*] [internal] When a user migrates to the Jetpack app and allows notifications, WordPress app notifications are disabled. [#19616, #19611, #19590]
+* [*] Reader now scrolls to the top if the tab bar button is tapped. [#19769]
+* [*] [Internal] Update WordPressShared, WordPressKit, and WordPressAuthenticator to their latest versions. [#19643]
+
+21.3
+-----
+* [*] Fixed a minor UI issue where the segmented control under My SIte was being clipped when "Home" is selected. [#19595]
+* [*] Fixed an issue where the site wasn't removed and the app wasn't refreshed after disconnecting the site from WordPress.com. [#19634]
+* [*] [internal] Fixed an issue where Jetpack extensions were conflicting with WordPress extensions. [#19665]
+
+21.2
+-----
+* [*] [internal] Refactored fetching posts in the Reader tab. [#19539]
+* [*] Fixed an issue where the message "No media matching your search" for the media picker is not visible [#19555]
+
+21.1
+-----
+* [**] [Jetpack-only] We added a new landing screen with a cool animation that responds to device motion! [#19251, #19264, #19277, #19381, #19404, #19410, #19432, #19434, #19442, #19443, #19468, #19469]
+* [*] [internal] Database access change: the 'new Core Data context structure' feature flag is turned on by default. [#19433]
+* [***] [Jetpack-only] Widgets are now on Jetpack. Find Today, This Week, and All Time Widgets to display your Stats on your home screen. [#19479]
+* [*] Block Editor: Fixed iOS Voice Control support within Image block captions. [https://github.com/WordPress/gutenberg/pull/44850]
+* [***] Dropped support for iOS 13. Now supporting iOS 14.0 and above. [#19509]
+
+21.0
+-----
+* [*] Fixed an issue where the cached notifications are retained after logging out of WordPress.com account [#19360]
+* [**] [Jetpack-only] Added a share extension. Now users can share content to Jetpack through iOS's share sheet. This was previously only available on the WordPress app. [#19383]
+* [*] Update launch screen. [#19341]
+* [*] [Jetpack-only] Add ability to set custom app icon for Jetpack app. [#19378]
+* [**] [Jetpack-only] Added a "Save as Draft" extension. Now users can save content to Jetpack through iOS's share sheet. This was previously only available on the WordPress app. [#19414]
+* [**] [Jetpack-only] Enables Rich Notifications for the Jetpack app. Now we display more details on most of the push notifications. This was previously only available on the WordPress app. [#19415]
+* [*] Reader: Comment Details have been redesigned. [#19387]
+* [*] [internal] A refactor in weekly roundup notification scheduler. [#19422]
+* [*] [internal] A low level database refactor around fetching cards in the Reader tab. [#19427]
+* [*] Stories: Fixed an issue where the keyboard would overlap with the publish dialog in landscape. [#19350]
+* [*] [internal] A refactor in fetch Reader posts and their comments. [#19458]
+* [*] Fixed an issue where the navigation bar becomes invisible when swiping back to Login Prologue screen. [#19461]
+
+20.9
+-----
+* [*] Login Flow: Provide ability for user to cancel login WP.com flow when already logged in to a self-hosted site [#19349]
+* [*] [WordPress-only] Powered by Jetpack banner: Fixed an edge case where some scroll views could momentarily become unresponsive to touch. [#19369]
+* [*] [Jetpack-only] Weekly roundup: Adds support for weekly roundup notifications to the Jetpack app. [#19364]
+* [*] Fixed an issue where the push notifications prompt button would overlap on iPad. [#19304]
+* [*] Story Post: Fixed an issue where deleting one image in a story draft would cause the following image not to load. [#16966]
+* [*] Fixed an issue where the no result label on the side menu is oversize on iPad. [#19305]
+* [*] [internal] Various low level database refactors around posts, pages, and comments. [#19353, #19363, #19386]
+
+20.8
+-----
+* [*] User Mention: When replying to a post or a comment, sort user-mentions suggestions by prefix first then alphabetically. [#19218]
+* [*] User Mention: Fixed an issue where the user-mentions suggestions were disappearing after expanding/collapsing the reply field. [#19248]
+* [***] [internal] Update Sentry, our crash monitoring tool, to its latest major version [#19315]
+
+20.7
+-----
+* [*] [Jetpack-only] Block Editor: Update link colors in action sheets from green to blue [https://github.com/WordPress/gutenberg/pull/42996]
+* [*] Jetpack Social: Rebrand Publicize to Jetpack Social [https://github.com/wordpress-mobile/WordPress-iOS/pull/19262]
+
+20.6
+-----
+* [*] [Jetpack-only] Recommend App: you can now share the Jetpack app with your friends. [#19174]
+* [*] [Jetpack-only] Feature Announcements: new features are highlighted via the What's New modals. [#19176]
+* [**] [Jetpack-only] Self-hosted sites: enables logging in via a self-hosted site / adding a self-hosted site [#19194]
+* [*] Pages List: Fixed an issue where the app would freeze when opening the pages list if one of the featured images is a GIF. [#19184]
+* [*] Stats: Fixed an issue where File Downloads section was being displayed for Jetpack sites even though it's not supported. [#19200]
+
+20.5
+-----
+* [*] [Jetpack-only] Block Editor: Makes some small changes to the editor's accent colours for consistency. [#19113]
+* [*] User Mention: Split the suggestions list into a prominent section and a regular section. [#19064]
+* [*] Use larger thumbnail previews for recommended themes during site creation [https://github.com/wordpress-mobile/WordPress-iOS/pull/18972]
+* [***] [internal] Block Editor: List block: Adds support for V2 behind a feature flag [https://github.com/WordPress/gutenberg/pull/42702]
+* [**] Fix for Referrers Card Not Showing Search Engine Details [https://github.com/wordpress-mobile/WordPress-iOS/pull/19158]
+* [*] WeeklyRoundupBackgroundTask - format notification body [https://github.com/wordpress-mobile/WordPress-iOS/pull/19144]
+
+20.4
+-----
+* [*] Site Creation: Fixed a bug in the design picker where the horizontal position of designs could be reset. [#19020]
+* [*] [internal] Block Editor: Add React Native FastImage [https://github.com/WordPress/gutenberg/pull/42009]
+* [*] Block Editor: Inserter displays block collections [https://github.com/WordPress/gutenberg/pull/42405]
+* [*] Block Editor: Fix incorrect spacing within Image alt text footnote [https://github.com/WordPress/gutenberg/pull/42504]
+* [***] Block Editor: Gallery and Image block - Performance improvements [https://github.com/WordPress/gutenberg/pull/42178]
+* [**] [WP.com and Jetpack sites with VideoPress] Prevent validation error when viewing VideoPress markup within app [https://github.com/Automattic/jetpack/pull/24548]
+* [*] [internal] Add Jetpack branding elements (badges and banners) [#19007, #19040, #19049, #19059, #19062, #19065, #19071, #19073, #19103, #19074, #19085, #19094, #19102, #19104]
+
+20.3
+-----
+* [*] Stories: Fixed a crash that could occur when adding multiple items to a Story post. [#18967]
+* [*] User Mention: When replying to a post or a comment, the post author or comment author shows up at the top of the suggestions list. [#18979]
+* [*] Block Editor: Fixed an issue where the media picker search query was being retained after dismissing the picker and opening it again. [#18980]
+* [*] Block Editor: Add 'Insert from URL' option to Video block [https://github.com/WordPress/gutenberg/pull/41493]
+* [*] Block Editor: Image block copies the alt text from the media library when selecting an item [https://github.com/WordPress/gutenberg/pull/41839]
+* [*] Block Editor: Introduce "block recovery" option for invalid blocks [https://github.com/WordPress/gutenberg/pull/41988]
+
+20.2
+-----
+* [*] Preview: Post preview now resizes to account for device orientation change. [#18921]
+* [***] [Jetpack-only] Enables QR Code Login scanning from the Me menu. [#18904]
+* [*] Reverted the app icon back to Cool Blue. Users can reselect last month's icon in Me > App Settings > App Icon if they'd like. [#18934]
+
+20.1
+-----
+* [*] Notifications: Fixed an issue where the first notification opened in landscape mode was not scrollable. [#18823]
+* [*] Site Creation: Enhances the design selection screen with recommended designs. [#18740]
+* [***] [Jetpack-only] Introducing blogging prompts. Build a writing habit and support creativity with a periodic prompt for inspiration. [#18860]
+* [**] Follow Conversation: A tooltip has been added to highlight the follow conversation feature. [#18848]
+* [*] [internal] Block Editor: Bump react-native-gesture-handler to version 2.3.2. [#18742]
+* [*] People Management: Fixed a crash that can occur when loading the People view. [#18907]
+
+20.0
+-----
+* [*] Quick Start: The "Get to know the WordPress app" card has a fresh new look [#18688, #18747]
+* [*] Block Editor: A11y: Improve text read by screen readers for BottomSheetSelectControl [https://github.com/WordPress/gutenberg/pull/41036]
+* [*] Block Editor: Add 'Insert from URL' option to Image block [https://github.com/WordPress/gutenberg/pull/40334]
+* [*] App Settings: refreshed the UI with updated colors for Media Cache Size controls, Clear Spot Index row button, and Clear Siri Shortcut Suggestions row button. From destructive (red color) to standard and brand colors. [#18636]
+* [*] [internal] Quick Start: Fixed an issue where the Quick Start modal was not displayed after login if the user's default tab is Home. [#18721]
+* [*] Quick Start: The Next Steps modal has a fresh new look [#18711]
+* [*] [internal] Quick Start: Fixed a couple of layout issues with the Quick Start notices when rotating the device. [#18758]
+
+19.9
+-----
+* [*] Site Settings: we fixed an issue that prevented the site title to be updated when it changed in Site Settings [#18543]
+* [*] Media Picker: Fixed an issue where the empty state view was being displayed incorrectly. [#18471]
+* [*] Quick Start: We are now showing a different set of Quick Start tasks for existing sites and new sites. The existing sites checklist includes new tours such as: "Check your notifications" and "Upload photos or videos". [#18395, #18412, #18443, #18471]
+* [*] Site Creation: we fixed an issue where the navigation buttons were not scaling when large fonts were selected on the device [#18559]
+* [**] Block Editor: Cover Block: Improve color contrast between background and text [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4808]
+* [***] Block Editor: Add drag & drop blocks feature [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4832]
+* [*] Block Editor: Gallery block: Fix broken "Link To" settings and add "Image Size" settings [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4841]
+* [*] Block Editor: Unsupported Block Editor: Prevent WordPress.com tour banner from displaying. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4820]
+* [*] Widgets: we fixed an issue where text appeared flipped in rtl languages [#18567]
+* [*] Stats: we fixed a crash that occurred sometimes in Stats [#18613]
+* [*] Posts list: we fixed an issue where the create button was not shown on iPad in split screen [#18609]
+
+19.8
+-----
+* [**] Self hosted sites are not restricted by video length during media uploads [https://github.com/wordpress-mobile/WordPress-iOS/pull/18414]
+* [*] [internal] My Site Dashboard: Made some changes to the code architecture of the dashboard. The majority of the changes are related to the posts cards. It should have no visible changes but could cause regressions. Please test it by creating/trashing drafts and scheduled posts and testing that they appear correctly on the dashboard. [#18405]
+* [*] Quick Start: Updated the Stats tour. The tour can now be accessed from either the dashboard or the menu tab. [#18413]
+* [*] Quick Start: Updated the Reader tour. The tour now highlights the Discover tab and guides users to follow topics via the Settings screen. [#18450]
+* [*] [internal] Quick Start: Deleted the Edit your homepage tour. [#18469]
+* [*] [internal] Quick Start: Refactored some code related to the tasks displayed in the Quick Start Card and the Quick Start modal. It should have no visible changes but could cause regressions. [#18395]
+* [**] Follow Conversation flow now enables in-app notifications by default. They were updated to be opt-out rather than opt-in. [#18449]
+* [*] Block Editor: Latest Posts block: Add featured image settings [https://github.com/WordPress/gutenberg/pull/39257]
+* [*] Block Editor: Prevent incorrect notices displaying when switching between HTML-Visual mode quickly [https://github.com/WordPress/gutenberg/pull/40415]
+* [*] Block Editor: Embed block: Fix inline preview cut-off when editing URL [https://github.com/WordPress/gutenberg/pull/35326]
+* [*] Block Editor: Prevent gaps shown around floating toolbar when using external keyboard [https://github.com/WordPress/gutenberg/pull/40266]
+* [**] We'll now ask users logging in which area of the app they'd like to focus on to build towards a more personalized experience. [#18385]
+
+19.7
+-----
+* [*] a11y: VoiceOver has been improved on the Menus view and now announces changes to ordering. [#18155]
+* [*] Notifications list: remove comment Trash swipe action. [#18349]
+* [*] Web previews now abide by safe areas when a toolbar is shown [#18127]
+* [*] Site creation: Adds a new screen asking the user the intent of the site [#18367]
+* [**] Block Editor: Quote block: Adds support for V2 behind a feature flag [https://github.com/WordPress/gutenberg/pull/40133]
+* [**] Block Editor: Update "add block" button's style in default editor view [https://github.com/WordPress/gutenberg/pull/39726]
+* [*] Block Editor: Remove banner error notification on upload failure [https://github.com/WordPress/gutenberg/pull/39694]
+* [*] My Site: display site name in My Site screen nav title [#18373]
+* [*] [internal] Site creation: Adds a new screen asking the user the name of the site [#18280]
+
+19.6
+-----
+* [*] Enhances the exit animation of notices. [#18182]
+* [*] Media Permissions: display error message when using camera to capture photos and media permission not given [https://github.com/wordpress-mobile/WordPress-iOS/pull/18139]
+* [***] My Site: your My Site screen now has two tabs, "Menu" and "Home". Under "Home", you'll find contextual cards with some highlights of whats going on with your site. Check your drafts or scheduled posts, your today's stats or go directly to another section of the app. [#18240]
+* [*] [internal] Site creation: Adds a new screen asking the user the intent of the site [#18270]
+
+19.5
+-----
+* [*] Improves the error message shown when trying to create a new site with non-English characters in the domain name [https://github.com/wordpress-mobile/WordPress-iOS/pull/17985]
+* [*] Quick Start: updated the design for the Quick Start cell on My Site [#18095]
+* [*] Reader: Fixed a bug where comment replies are misplaced after its parent comment is moderated [#18094]
+* [*] Bug fix: Allow keyboard to be dismissed when the password field is focused during WP.com account creation.
+* [*] iPad: Fixed a bug where the current displayed section wasn't selected on the menu [#18118]
+* [**] Comment Notifications: updated UI and functionality to match My Site Comments. [#18141]
+* [*] Block Editor: Add GIF badge for animated GIFs uploaded to Image blocks [https://github.com/WordPress/gutenberg/pull/38996]
+* [*] Block Editor: Small refinement to media upload errors, including centering and tweaking copy. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4597]
+* [*] Block Editor: Fix issue with list's starting index and the order [https://github.com/WordPress/gutenberg/pull/39354]
+* [*] Quick Start: Fixed a bug where a user creating a new site is displayed a quick start tour containing data from their presviously active site.
+
+19.4
+-----
+* [*] Site Creation: Fixed layout of domain input field for RTL languages. [#18006]
+* [*] [internal] The FAB (blue button to create posts/stories/pages) creation/life cycle was changed [#18026]
+* [*] Stats: we fixed a variety of performance issues in the Insight screen. [#17926, #17936, #18017]
+* [*] Stats: we re-organized the default view in Insights, presenting more interesting data at a glance [#18072]
+* [*] Push notifications will now display rich media when long pressed. [#18048]
+* [*] Weekly Roundup: We made some further changes to try and ensure that Weekly Roundup notifications are showing up for everybody who's enabled them [#18029]
+* [*] Block editor: Autocorrected Headings no longer apply bold formatting if they weren't already bold. [#17844]
+* [***] Block editor: Support for multiple color palettes [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4588]
+* [**] User profiles: Fixed issue where the app wasn't displaying any of the device photos which the user had granted the app access to.
+
+19.3
+-----
+* [*] Site previews: Reduced visual flickering when previewing sites and templates. [#17861]
+* [*] Stats: Scroll to new Insights card when added. [#17894]
+* [*] Add "Copy Link" functionality to Posts List and Pages List [#17911]
+* [*] [Jetpack-only] Enables the ability to use and create WordPress.com sites, and enables the Reader tab. [#17914, #17948]
+* [*] Block editor: Additional error messages for media upload failures. [#17971]
+* [**] Adds animated Gif support in notifications and comments [#17981]
+
+19.2
+-----
+* [*] Site creation: Fixed bug where sites created within the app were not given the correct time zone, leading to post scheduling issues. [#17821]
+* [*] Block editor: Replacing the media for an image set as featured prompts to update the featured image [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3930]
+* [***] Block editor: Font size and line-height support for text-based blocks used in block-based themes [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4519]
+* [**] Some of the screens of the app has a new, fresh and more modern visual, including the initial one: My Site. [#17812]
+* [**] Notifications: added a button to mark all notifications in the selected filter as read. [#17840]
+* [**] People: you can now manage Email Followers on the People section! [#17854]
+* [*] Stats: fix navigation between Stats tab. [#17856]
+* [*] Quick Start: Fixed a bug where a user logging in via a self-hosted site not connected to Jetpack would see Quick Start when selecting "No thanks" on the Quick Start prompt. [#17855]
+* [**] Threaded comments: comments can now be moderated via a drop-down menu on each comment. [#17888]
+* [*] Stats: Users can now add a new Insights card from the navigation bar. [#17867]
+* [*] Site creation: The checkbox that appears when choosing a design no longer flickers when toggled. [#17868]
+
+19.1
+-----
+* [*] Signup: Fixed bug where username selection screen could be pushed twice. [#17624]
+* [**] Reader post details Comments snippet: added ability to manage conversation subscription and notifications. [#17749]
+* [**] Accessibility: VoiceOver and Dynamic Type improvements on Activity Log and Schedule Post calendars [#17756, #17761, #17780]
+* [*] Weekly Roundup: Fix a crash which was preventing weekly roundup notifications from appearing [#17765]
+* [*] Self-hosted login: Improved error messages. [#17724]
+* [*] Share Sheet from Photos: Fix an issue where certain filenames would not upload or render in Post [#16773]
+* [*] Block editor: Fixed an issue where video thumbnails could show when selecting images, and vice versa. [#17670]
+* [**] Media: If a user has only enabled limited device media access, we now show a prompt to allow the user to change their selection. [#17795]
+* [**] Block editor: Fix content justification attribute in Buttons block [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4451]
+* [*] Block editor: Hide help button from Unsupported Block Editor. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4352]
+* [*] Block editor: Add contrast checker to text-based blocks [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4357]
+* [*] Block editor: Fix missing translations of color settings [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4479]
+* [*] Block editor: Highlight text: fix applying formatting for non-selected text [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4471]
+* [***] Self-hosted sites: Fixed a crash when saving media and no Internet connection was available. [#17759]
+* [*] Publicize: Fixed an issue where a successful login was not automatically detected when connecting a Facebook account to Publicize. [#17803]
+
+19.0
+-----
+* [**] Video uploads: video upload is now limited to 5 minutes per video on free plans. [#17689]
+* [*] Block editor: Give multi-line block names central alignment in inserter [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4343]
+* [**] Block editor: Fix missing translations by refactoring the editor initialization code [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4332]
+* [**] Block editor: Add Jetpack and Layout Grid translations [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4359]
+* [**] Block editor: Fix text formatting mode lost after backspace is used [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4423]
+* [*] Block editor: Add missing translations of unsupported block editor modal [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4410]
+* [**] Time zone suggester: we have a new time zone selection screen that suggests the time zone based on the device, and improves search. [#17699]
+* [*] Added the "Share WordPress with a friend" row back to the Me screen. [#17748]
+* [***] Updated default app icon. [#17793]
+
+18.9
+-----
+* [***] Reader Comments: Updated comment threads with a new design and some new capabilities. [#17659]
+* [**] Block editor: Fix issue where editor doesn't auto-scroll so you can see what is being typed. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4299]
+* [*] Block editor: Preformatted block: Fix an issue where the background color is not showing up for standard themes. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4292]
+* [**] Block editor: Update Gallery Block to default to the new format and auto-convert old galleries to the new format. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4315]
+* [***] Block editor: Highlight text: Enables color customization for specific text within a Paragraph block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4175]
+* [**] Reader post details: a Comments snippet is now displayed after the post content. [#17650]
+
+18.8
+-----
+* [*] Added a new About screen, with links to rate the app, share it with others, visit our Twitter profile, view our other apps, and more. [https://github.com/orgs/wordpress-mobile/projects/107]
+* [*] Editor: Show a compact notice when switching between HTML or Visual mode. [https://github.com/wordpress-mobile/WordPress-iOS/pull/17521]
+* [*] Onboarding Improvements: Need a little help after login? We're here for you. We've made a few changes to the login flow that will make it easier for you to start managing your site or create a new one. [#17564]
+* [***] Fixed crash where uploading image when offline crashes iOS app. [#17488]
+* [***] Fixed crash that was sometimes triggered when deleting media. [#17559]
+* [***] Fixes a crasher that was sometimes triggered when seeing the details for like notifications. [#17529]
+* [**] Block editor: Add clipboard link suggestion to image block and button block. [https://github.com/WordPress/gutenberg/pull/35972]
+* [*] Block editor: Embed block: Include link in block settings. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4189]
+* [**] Block editor: Fix tab titles translation of inserter menu. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4248]
+* [**] Block editor: Gallery block: When a gallery block is added, the media options are auto opened for v2 of the Gallery block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4277]
+* [*] Block editor: Media & Text block: Fix an issue where the text font size would be bigger than expected in some cases. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4252]
+
+18.7
+-----
+* [*] Comment Reply: updated UI. [#17443, #17445]
+* [***] Two-step Authentication notifications now require an unlocked device to approve or deny them.
+* [***] Site Comments: Updated comment details with a fresh new look and capability to display rich contents. [#17466]
+* [**] Block editor: Image block: Add ability to quickly link images to Media Files and Attachment Pages [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3971]
+* [**] Block editor: Fixed a crash that could occur when copying lists from Microsoft Word. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4174]
+* [***] Fixed an issue where trying to upload an image while offline crashes the app. [#17488]
+
+18.6
+-----
+* [**] Comments: Users can now follow conversation via notifications, in addition to emails. [#17363]
+* [**] Block editor: Block inserter indicates newly available block types [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4047]
+* [*] Reader post comments: fixed an issue that prevented all comments from displaying. [#17373]
+* [**] Stats: added Reader Discover nudge for sites with low traffic in order to increase it. [#17349, #17352, #17354, #17377]
+* [**] Block editor: Search block - Text and background color support [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4127]
+* [*] Block editor: Fix Embed Block loading glitch with resolver resolution approach [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4146]
+* [*] Block editor: Fixed an issue where the Help screens may not respect an iOS device's notch. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4110]
+* [**] Block editor: Block inserter indicates newly available block types [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4047]
+* [*] Block editor: Add support for the Mark HTML tag [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4162]
+* [*] Stats Insights: HTML tags no longer display in post titles. [#17380]
+
+18.5
+-----
+* [**] Block editor: Embed block: Include Jetpack embed variants. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4008]
+* [*] Fixed a minor visual glitch on the pre-publishing nudge bottom sheet. [https://github.com/wordpress-mobile/WordPress-iOS/pull/17300]
+* [*] Improved support for larger text sizes when choosing a homepage layout or page layout. [#17325]
+* [*] Site Comments: fixed an issue that caused the lists to not refresh. [#17303]
+* [*] Block editor: Embed block: Fix inline preview cut-off when editing URL [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4072]
+* [*] Block editor: Embed block: Fix URL not editable after dismissing the edit URL bottom sheet with empty value [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4094]
+* [**] Block editor: Embed block: Detect when an embeddable URL is pasted into an empty paragraph. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4048]
+* [**] Block editor: Pullquote block - Added support for text and background color customization [https://github.com/WordPress/gutenberg/pull/34451]
+* [**] Block editor: Preformatted block - Added support for text and background color customization [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4071]
+* [**] Stats: added Publicize and Blogging Reminders nudges for sites with low traffic in order to increase it. [#17142, #17261, #17294, #17312, #17323]
+* [**] Fixed an issue that made it impossible to log in when emails had an apostrophe. [#17334]
+
+18.4
+-----
+* [*] Improves our user images download logic to avoid synchronization issues. [#17197]
+* [*] Fixed an issue where images point to local URLs in the editor when saving a post with ongoing uploads. [#17157]
+* [**] Embed block: Add the top 5 specific embed blocks to the Block inserter list. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3995]
+* [*] Embed block: Fix URL update when edited after setting a bad URL of a provider. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4002]
+* [**] Users can now contact support from inside the block editor screen. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3975]
+* [**] Block editor: Help menu with guides about how to work with blocks [#17265]
+
+18.3
+-----
+* [*] Fixed a bug on Reader that prevented Saved posts to be removed
+* [*] Share Extension: Allow creation of Pages in addition to Posts. [#16084]
+* [*] Updated the wording for the "Posts" and "Pages" entries in My Site screen [https://github.com/wordpress-mobile/WordPress-iOS/pull/17156]
+* [**] Fixed a bug that prevented sharing images and videos out of your site's media library. [#17164]
+* [*] Fixed an issue that caused `Follow conversation by email` to not appear on some post's comments. [#17159]
+* [**] Block editor: Embed block: Enable WordPress embed preview [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3853]
+* [**] Block editor: Embed block: Add error bottom sheet with retry and convert to link actions. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3921]
+* [**] Block editor: Embed block: Implemented the No Preview UI when an embed is successful, but we're unable to show an inline preview [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3927]
+* [*] Block editor: Embed block: Add device's locale to preview content [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3788]
+* [*] Block editor: Column block: Translate column width's control labels [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3952]
+* [**] Block editor: Embed block: Enable embed preview for Instagram and Vimeo providers. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3918]
+
+18.2
+-----
+* [internal] Fixed an issue where source and platform tags were not added to a Zendesk ticket if the account has no blogs. [#17084]
+* [*] Set the post formats to have 'Standard' first and then alphabetized the remaining items. [#17074]
+* [*] Fixed wording of theme customization screen's menu bar by using "Activate" on inactive themes. [#17060]
+* [*] Added pull-to-refresh to My Site. [#17089]
+* [***] Weekly Roundup: users will receive a weekly notification that presents a summary of the activity on their most used sites [#17066, #17116]
+* [**] Site Comments: when editing a Comment, the author's name, email address, and web address can now be changed. [#17111]
+* [**] Block editor: Enable embed preview for a list of providers (for now only YouTube and Twitter) [https://github.com/WordPress/gutenberg/pull/34446]
+* [***] Block editor: Add Inserter Block Search [https://github.com/WordPress/gutenberg/pull/33237]
+
+18.1
+-----
+* [*] Reader: Fixes an issue where the top of an article could be cropped after rotating a device. [#17041]
+* [*] Posts Settings: Removed deprecated Location feature. [#17052]
+* [**] Added a time selection feature to Blogging Reminders: users can now choose at what time they will receive the reminders [#17024, #17033]
+* [**] Block editor: Embed block: Add "Resize for smaller devices" setting. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3753]
+* [**] Account Settings: added the ability to close user account.
+* [*] Users can now share WordPress app with friends. Accessible from Me and About screen. [#16995]
+
+18.0
+-----
+* [*] Fixed a bug that would make it impossible to scroll the plugins the first time the plugin section was opened.
+* [*] Resolved an issue where authentication tokens weren't be regenerated when disabled on the server. [#16920]
+* [*] Updated the header text sizes to better support large texts on Choose a Domain and Choose a Design flows. [#16923]
+* [internal] Made a change to how Comment content is displayed. Should be no visible changes, but could cause regressions. [#16933]
+* [internal] Converted Comment model properties to Swift. Should be no functional changes, but could cause regressions. [#16969, #16980]
+* [internal] Updated GoogleSignIn to 6.0.1 through WordPressAuthenticator. Should be no visible changes, but could cause regression in Google sign in flow. [#16974]
+* [internal] Converted Comment model properties to Swift. Should be no functional changes, but could cause regressions. [#16969]
+* [*] Posts: Ampersands are correctly decoded in publishing notices instead of showing as HTML entites. [#16972]
+* [***] Adjusted the image size of Theme Images for more optimal download speeds. [#16914]
+* [*] Comments and Notifications list are now displayed with a unified design. [#16985]
+* [*] Block editor: Add a "featured" banner and ability to set or remove an image as featured. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3449]
+
+17.9
+-----
+* [internal] Redirect Terms and service to open the page in an external web view [#16907]
+* [internal] Converted Comment model methods to Swift. Should be no functional changes, but could cause regressions. [#16898, #16905, #16908, #16913]
+* [*] Enables Support for Global Style Colors with Full Site Editing Themes [#16823]
+* [***] Block editor: New Block: Embed block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3727]
+
+17.8
+-----
+* [*] Authors and Contributors can now view a site's Comments via My Site > Comments. [#16783]
+* [*] [Jetpack-only] Fix bugs when tapping to notifications
+* [*] Fixed some refresh issues with the site follow buttons in the reader. [#16819]
+* [*] Block editor: Update loading and failed screens for web version of the editor [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3573]
+* [*] Block editor: Handle floating keyboard case - Fix issue with the block selector on iPad. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3687]
+* [**] Block editor: Added color/background customization for text blocks. [https://github.com/WordPress/gutenberg/pull/33250]
+
+17.7
+-----
+* [***] Added blogging reminders. Choose which days you'd like to be reminded, and we'll send you a notification prompting you to post on your site
+* [** Does not apply to Jetpack app] Self hosted sites that do not use Jetpack can now manage (install, uninstall, activate, and deactivate) their plugins [#16675]
+* [*] Upgraded the Zendesk SDK to version 5.3.0
+* [*] You can now subscribe to conversations by email from Reader lists and articles. [#16599]
+* [*] Block editor: Tablet view fixes for inserter button. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3602]
+* [*] Block editor: Tweaks to the badge component's styling, including change of background color and reduced padding. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3642]
+* [***] Block editor: New block Layout grid. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3513]
+* [*] Fixed an issue where the SignUp flow could not be dismissed sometimes. [#16824]
+
+17.6
+-----
+* [**] Reader Post details: now shows a summary of Likes for the post. Tapping it displays the full list of Likes. [#16628]
+* [*] Fix notice overlapping the ActionSheet that displays the Site Icon controls. [#16579]
+* [*] Fix login error for WordPress.org sites to show inline. [#16614]
+* [*] Disables the ability to open the editor for Post Pages [#16369]
+* [*] Fixed an issue that could cause a crash when moderating Comments. [#16645]
+* [*] Fix notice overlapping the ActionSheet that displays the QuickStart Removal. [#16609]
+* [*] Site Pages: when setting a parent, placeholder text is now displayed for pages with blank titles. [#16661]
+* [***] Block Editor: Audio block now available on WP.com sites on the free plan. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3523]
+* [**] You can now create a Site Icon for your site using an emoji. [#16670]
+* [*] Fix notice overlapping the ActionSheet that displays the More Actions in the Editor. [#16658]
+* [*] The quick action buttons will be hidden when iOS is using a accessibility font sizes. [#16701]
+* [*] Block Editor: Improve unsupported block message for reusable block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3621]
+* [**] Block Editor: Fix incorrect block insertion point after blurring the post title field. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3640]
+* [*] Fixed a crash when sharing photos to WordPress [#16737]
+
+17.5
+-----
+* [*] Fixed a crash when rendering the Noticons font in rich notification. [#16525]
+* [**] Block Editor: Audio block: Add Insert from URL functionality. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3031]
+* [***] Block Editor: Slash command to insert new blocks. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3250]
+* [**] Like Notifications: now displays all users who liked a post or comment. [#15662]
+* [*] Fixed a bug that was causing some fonts to become enormous when large text was enabled.
+* [*] Fixed scrolling and item selection in the Plugins directory. [#16087]
+* [*] Improved large text support in the blog details header in My Sites. [#16521]
+* [***] Block Editor: New Block: Reusable block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3490]
+* [***] Block Editor: Add reusable blocks to the block inserter menu. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3054]
+* [*] Fixed a bug where the web version of the editor did not load when using an account created before December 2018. [#16586]
+
+17.4
+-----
+* [**] A new author can be chosen for Posts and Pages on multi-author sites. [#16281]
+* [*] Fixed the Follow Sites Quick Start Tour so that Reader Search is highlighted. [#16391]
+* [*] Enabled approving login authentication requests via push notification while the app is in the foreground. [#16075]
+* [**] Added pull-to-refresh to the My Site screen when a user has no sites. [#16241]
+* [***] Fixed a bug that was causing uploaded videos to not be viewable in other platforms. [#16548]
+
+17.3
+-----
+* [**] Fix issue where deleting a post and selecting undo would sometimes convert the content to the classic editor. [#16342]
+* [**] Fix issue where restoring a post left the restored post in the published list even though it has been converted to a draft. [#16358]
+* [**] Fix issue where trashing a post converted it to Classic content. [#16367]
+* [**] Fix issue where users could not leave the username selection screen due to styling issues. [#16380]
+* [*] Comments can be filtered to show the most recent unreplied comments from other users. [#16215]
+* [*] Fixed the background color of search fields. [#16365]
+* [*] Fixed the navigation bar color in dark mode. [#16348]
+* [*] Fix translation issues for templates fetched on the site creation design selection screen. [#16404]
+* [*] Fix translation issues for templates fetched on the page creation design selection screen. [#16404]
+* [*] Fix translation issue for the Choose button on the template preview in the site creation flow. [#16404]
+* [***] Block Editor: New Block: Search Block [#https://github.com/wordpress-mobile/gutenberg-mobile/pull/3210]
+* [**] Block Editor: The media upload options of the Image, Video and Gallery block automatically opens when the respective block is inserted. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2700]
+* [**] Block Editor: The media upload options of the File and Audio block automatically opens when the respective block is inserted. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3399]
+* [*] Block Editor: Remove visual feedback from non-interactive bottom-sheet cell sections [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3404]
+* [*] Block Editor: Fixed an issue that was causing the featured image badge to be shown on images in an incorrect manner. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3494]
+
+
+17.2
+-----
+
+* [**] Added transform block capability [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3321]
+* [*] Fixed an issue where some author display names weren't visible for self-hosted sites. [#16297]
+* [***] Updated custom app icons. [#16261]
+* [**] Removed Site Switcher in the Editor
+* [*] a11y: Bug fix: Allow stepper cell to be selected by screenreader [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3362]
+* [*] Image block: Improve text entry for long alt text. [https://github.com/WordPress/gutenberg/pull/29670]
+* [***] New Block: Jetpack contact info. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3340]
+
+17.1
+-----
+
+* [*] Reordered categories in page layout picker [#16156]
+* [*] Added preview device mode selector in the page layout previews [#16141]
+* [***] Block Editor: Improved the accessibility of range and step-type block settings. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3255]
+* [**] Block Editor: Added Contact Info block to sites on WPcom or with Jetpack version >= 8.5.
+* [**] We updated the app's color scheme with a brighter new blue used throughout. [#16213, #16207]
+* [**] We updated the login prologue with brand new content and graphics. [#16159, #16177, #16185, #16187, #16200, #16217, #16219, #16221, #16222]
+* [**] We updated the app's color scheme with a brighter new blue used throughout. [#16213, #16207]
+* [**] Updated the app icon to match the new color scheme within the app. [#16220]
+* [*] Fixed an issue where some webview navigation bar controls weren't visible. [#16257]
+
+17.0
+-----
+* [internal] Updated Zendesk to latest version. Should be no functional changes. [#16051]
+* [*] Reader: fixed an issue that caused unfollowing external sites to fail. [#16060]
+* [*] Stats: fixed an issue where an error was displayed for Latest Post Summary if the site had no posts. [#16074]
+* [*] Fixed an issue where password text on Post Settings was showing as black in dark mode. [#15768]
+* [*] Added a thumbnail device mode selector in the page layout, and use a default setting based on the current device. [#16019]
+* [**] Comments can now be filtered by status (All, Pending, Approved, Trashed, or Spam). [#15955, #16110]
+* [*] Notifications: Enabled the new view milestone notifications [#16144]
+* [***] We updated the app's design, with fresh new headers throughout and a new site switcher in My Site. [#15750]
+
+16.9
+-----
+* [*] Adds helper UI to Choose a Domain screen to provide a hint of what a domain is. [#15962]
+* [**] Site Creation: Adds filterable categories to the site design picker when creating a WordPress.com site, and includes single-page site designs [#15933]
+* [**] The classic editor will no longer be available for new posts soon, but this won’t affect editing any existing posts or pages. Users should consider switching over to the Block Editor now. [#16008]
+* [**] Reader: Added related posts to the bottom of reader posts
+* [*] Reader: We redesigned the recommended topics section of Discover
+* [*] Reader: Added a way to discover new topics from the Manage Topics view
+* [*] P2 users can create and share group invite links via the Invite Person screen under the People Management feature. [#16005]
+* [*] Fixed an issue that prevented searching for plugins and the Popular Plugins section from appearing: [#16070]
+* [**] Stories: Fixed a video playback issue when recording on iPhone 7, 8, and SE devices. [#16109]
+* [*] Stories: Fixed a video playback issue when selecting an exported Story video from a site's library. [#16109]
+
+16.8.1
+-----
+
+* [**] Stories: Fixed an issue which could remove content from a post when a new Story block was edited. [#16059]
+
+16.8
+-----
+* [**] Prevent deleting published homepages which would have the effect of breaking a site. [#15797]
+* [**] Prevent converting published homepage to a draft in the page list and settings which would have the effect of breaking a site. [#15797]
+* [*] Fix app crash when device is offline and user visits Notification or Reader screens [#15916]
+* [*] Under-the-hood improvements to the Reader Stream, People Management, and Sharing Buttons [#15849, #15861, #15862]
+* [*] Block Editor: Fixed block mover title wording for better clarity from 'Move block position' to 'Change block position'. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3049]
+* [**] Block Editor: Add support for setting Cover block focal point. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3028]
+* [**] Prevent converting published homepage to a draft in the page list and editor's status settings which would have the effect of breaking a site. [#15797]
+* [*] Prevent selection of unpublished homepages the homepage settings which would have the effect of breaking a site. [#15885]
+* [*] Quick Start: Completing a step outside of a tour now automatically marks it as complete. [#15712]
+* [internal] Site Comments: updated UI. Should be no functional changes. [#15944]
+* [***] iOS 14 Widgets: new This Week Widgets to display This Week Stats in your home screen. [#15844]
+* [***] Stories: There is now a new Story post type available to quickly and conveniently post images and videos to your blog.
+
+16.7
+-----
+* [**] Site Creation: Adds the option to choose between mobile, tablet or desktop thumbnails and previews in the home page design picker when creating a WordPress.com site [https://github.com/wordpress-mobile/WordPress-iOS/pull/15688]
+* [*] Block Editor: Fix issue with uploading media after exiting the editor multiple times [https://github.com/wordpress-mobile/WordPress-iOS/pull/15656].
+* [**] Site Creation: Enables dot blog subdomains for each site design. [#15736]
+* [**] Reader post card and post details: added ability to mark a followed post as seen/unseen. [#15638, #15645, #15676]
+* [**] Reader site filter: show unseen post count. [#15581]
+* [***] Block Editor: New Block: Audio [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2854, https://github.com/wordpress-mobile/gutenberg-mobile/pull/3070]
+* [**] Block Editor: Add support for setting heading anchors [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2947]
+* [**] Block Editor: Disable Unsupported Block Editor for Reusable blocks [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3067]
+* [**] Block Editor: Add proper handling for single use blocks such as the more block [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3042]
+* [*] Reader post options: fixed an issue where the options in post details did not match those on post cards. [#15778]
+* [***] iOS 14 Widgets: new All Time Widgets to display All Time Stats in your home screen. [#15771, #15794]
+* [***] Jetpack: Backup and Restore is now available, depending on your sites plan you can now restore your site to a point in time, or download a backup file. [https://github.com/wordpress-mobile/WordPress-iOS/issues/15191]
+* [***] Jetpack: For sites that have Jetpack Scan enabled you will now see a new section that allows you to scan your site for threats, as well as fix or ignore them. [https://github.com/wordpress-mobile/WordPress-iOS/issues/15190]
+* [**] Block Editor: Make inserter long-press options "add to beginning" and "add to end" always available. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3074]
+* [*] Block Editor: Fix crash when Column block width attribute was empty. [https://github.com/WordPress/gutenberg/pull/29015]
+
+16.6
+-----
+* [**] Activity Log: adds support for Date Range and Activity Type filters. [https://github.com/wordpress-mobile/WordPress-iOS/issues/15192]
+* [*] Quick Start: Removed the Browse theme step and added guidance for reviewing pages and editing your Homepage. [#15680]
+* [**] iOS 14 Widgets: new Today Widgets to display your Today Stats in your home screen.
+* [*] Fixes an issue where the submit button was invisible during the domain registration flow.
+
+16.5
+-----
+
+* [*] In the Pages screen, the options to delete posts are styled to reflect that they are destructive actions, and show confirmation alerts. [#15622]
+* [*] In the Comments view, overly-large twemoji are sized the same as Apple's emoji. [#15503]
+* [*] Reader 'P2s': added ability to filter by site. [#15484]
+* [**] Choose a Domain will now return more options in the search results, sort the results to have exact matches first, and let you know if no exact matches were found. [#15482]
+* [**] Page List: Adds duplicate page functionality [#15515]
+* [*] Invite People: add link to user roles definition web page. [#15530]
+* [***] Block Editor: Cross-post suggestions are now available by typing the + character (or long-pressing the toolbar button labelled with an @-symbol) in a post on a P2 site [#15139]
+* [***] Block Editor: Full-width and wide alignment support for Columns (https://github.com/wordpress-mobile/gutenberg-mobile/pull/2919)
+* [**] Block Editor: Image block - Add link picker to the block settings and enhance link settings with auto-hide options (https://github.com/wordpress-mobile/gutenberg-mobile/pull/2841)
+* [*] Block Editor: Fix button link setting, rel link will not be overwritten if modified by the user (https://github.com/wordpress-mobile/gutenberg-mobile/pull/2894)
+* [**] Block Editor: Added move to top/bottom when long pressing on respective block movers (https://github.com/wordpress-mobile/gutenberg-mobile/pull/2872)
+* [**] Reader: Following now only shows non-P2 sites. [#15585]
+* [**] Reader site filter: selected filters now persist while in app.[#15594]
+* [**] Block Editor: Fix crash in text-based blocks with custom font size [https://github.com/WordPress/gutenberg/pull/28121]
+
+16.4
+-----
+
+* [internal] Removed unused Reader files. Should be no functional changes. [#15414]
+* [*] Adjusted the search box background color in dark mode on Choose a domain screen to be full width. [https://github.com/wordpress-mobile/WordPress-iOS/pull/15419]
+* [**] Added shadow to thumbnail cells on Site Creation and Page Creation design pickers to add better contrast [https://github.com/wordpress-mobile/WordPress-iOS/pull/15418]
+* [*] For DotCom and Jetpack sites, you can now subscribe to comments by tapping the "Follow conversation" button in the Comments view. [#15424]
+* [**] Reader: Added 'P2s' stream. [#15442]
+* [*] Add a new P2 default site icon to replace the generic default site icon. [#15430]
+* [*] Block Editor: Fix Gallery block uploads when the editor is closed. [#15457]
+* [*] Reader: Removes gray tint from site icons that contain transparency (located in Reader > Settings > Followed sites). [#15474]
+* [*] Prologue: updates site address button to say "Enter your existing site address" to reduce confusion with site creation actions. [#15481]
+* [**] Posts List: Adds duplicate post functionality [#15460]
+* [***] Block Editor: New Block: File [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2835]
+* [*] Reader: Removes gray tint from site icons that contain transparency (located in Reader > Settings > Followed sites).
+* [*] Block Editor: Remove popup informing user that they will be using the block editor by default [#15492]
+* [**] Fixed an issue where the Prepublishing Nudges Publish button could be cut off smaller devices [#15525]
+
+16.3
+-----
+* [***] Login: Updated to new iOS 14 pasteboard APIs for 2FA auto-fill. Pasteboard prompts should be less intrusive now! [#15454]
+* [***] Site Creation: Adds an option to pick a home page design when creating a WordPress.com site. [multiple PRs](https://github.com/search?q=repo%3Awordpress-mobile%2FWordPress-iOS+++repo%3Awordpress-mobile%2FWordPress-iOS-Shared+repo%3Awordpress-mobile%2FWordPressUI-iOS+repo%3Awordpress-mobile%2FWordPressKit-iOS+repo%3Awordpress-mobile%2FAztecEditor-iOS+is%3Apr+closed%3A%3C2020-11-17+%22Home+Page+Picker%22&type=Issues)
+
+* [**] Fixed a bug where @-mentions didn't work on WordPress.com sites with plugins enabled [#14844]
+* [***] Site Creation: Adds an option to pick a home page design when creating a WordPress.com site. [multiple PRs](https://github.com/search?q=repo%3Awordpress-mobile%2FWordPress-iOS+++repo%3Awordpress-mobile%2FWordPress-iOS-Shared+repo%3Awordpress-mobile%2FWordPressUI-iOS+repo%3Awordpress-mobile%2FWordPressKit-iOS+repo%3Awordpress-mobile%2FAztecEditor-iOS+is%3Apr+closed%3A%3C2020-11-30+%22Home+Page+Picker%22&type=Issues)
+* [*] Fixed an issue where `tel:` and `mailto:` links weren't launching actions in the webview found in Reader > post > more > Visit. [#15310]
+* [*] Reader bug fix: tapping a telephone, sms or email link in a detail post in Reader will now respond with the correct action. [#15307]
+* [**] Block Editor: Button block - Add link picker to the block settings [https://github.com/WordPress/gutenberg/pull/26206]
+* [***] Block Editor: Adding support for selecting different unit of value in Cover and Columns blocks [https://github.com/WordPress/gutenberg/pull/26161]
+* [*] Block Editor: Fix theme colors syncing with the editor [https://github.com/WordPress/gutenberg/pull/26821]
+* [*] My Site > Settings > Start Over. Correcting a translation error in the detailed instructions on the Start Over view. [#15358]
+
+16.2
+-----
+* [**] Support contact email: fixed issue that prevented non-alpha characters from being entered. [#15210]
+* [*] Support contact information prompt: fixed issue that could cause the app to crash when entering email address. [#15210]
+* [*] Fixed an issue where comments viewed in the Reader would always be italicized.
+* [**] Jetpack Section - Added quick and easy access for all the Jetpack features (Stats, Activity Log, Jetpack and Settings) [#15287].
+* [*] Fixed a display issue with the time picker when scheduling posts on iOS 14. [#15392]
+
+16.1
+-----
+* [***] Block Editor: Adds new option to select from a variety of predefined page templates when creating a new page for a Gutenberg site.
+* [*] Fixed an issue that was causing the refresh control to show up on top of the list of sites. [https://github.com/wordpress-mobile/WordPress-iOS/pull/15136]
+* [***] The "Floating Action Button" now appears on the list of posts and pages for quick and convenient creation. [https://github.com/wordpress-mobile/WordPress-iOS/pull/15149l]
+
+16.0
+-----
+* [***] Block Editor: Full-width and wide alignment support for Video, Latest-posts, Gallery, Media & text, and Pullquote block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2605]
+* [***] Block Editor: Fix unsupported block bottom sheet is triggered when device is rotated. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2710]
+* [***] Block Editor: Unsupported Block Editor: Fixed issue when cannot view or interact with the classic block on Jetpack site. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2709]
+* [**] Reader: Select interests is now displayed under the Discover tab. [#15097]
+* [**] Reader: The reader now displays site recommendations in the Discover feed [#15116]
+* [***] Reader: The new redesigned Reader detail shows your post as beautiful as ever. And if you add a featured image it would be twice as beautiful! [#15107]
+
+15.9
+-----
+* [*] Fixed issue that caused duplicate views to be displayed when requesting a login link. [#14975]
+* [internal] Modified feature flags that show unified Site Address, Google, Apple, WordPress views and iCloud keychain login. Could cause regressions. [#14954, #14969, #14970, #14971, #14972]
+* [*] Fixed an issue that caused page editor to become an invisible overlay. [#15012]
+* [**] Block Editor: Increase tap-target of primary action on unsupported blocks. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2608]
+* [***] Block Editor: On Jetpack connected sites, Unsupported Block Editor can be enabled via enabling Jetpack SSO setting directly from within the missing block alert. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2610]
+* [***] Block Editor: Add support for selecting user's post when configuring the link [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2484]
+* [*] Reader: Fixed an issue that resulted in no action when tapping a link with an anchor. [#15027]
+* [***] Block Editor: Unsupported Block Editor: Fixed issue when cannot view or interact with the classic block on Jetpack sites [https://github.com/wordpress-mobile/gutenberg-mobile/issues/2695]
+
+15.8
+-----
+* [*] Image Preview: Fixes an issue where an image would be incorrectly positioned after changing device orientation.
+* [***] Block Editor: Full-width and wide alignment support for Group, Cover and Image block [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2559]
+* [**] Block Editor: Add support for rounded style in Image block [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2591]
+* [*] Fixed an issue where the username didn't display on the Signup Epilogue after signing up with Apple and hiding the email address. [#14882]
+* [*] Login: display correct error message when the max number of failed login attempts is reached. [#14914]
+* [**] Block Editor: Fixed a case where adding a block made the toolbar jump [https://github.com/WordPress/gutenberg/pull/24573]
+
+15.7
+-----
+* [**] Updated UI when connecting a self-hosted site from Login Epilogue, My Sites, and Post Signup Interstitial. (#14742)
+* [**] You can now follow conversations for P2 sites
+* [**] Block Editor: Block settings now immediately reflect changes from menu sliders.
+* [**] Simplified authentication and updated UI.(#14845, #14831, #14825, #14817).
+ Now when an email address is entered, the app automatically determines the next step and directs the user accordingly. (i.e. signup or login with the appropriate login view).
+* [**] Added iCloud Keychain login functionality. (#14770)
+* [***] Reader: We’re introducing a new Reader experience that allows users to tailor their Discover feed to their chosen interests.
+* [*] Media editing: Reduced memory usage when marking up an image, which could cause a crash.
+* [**] Block Editor: Fixed Dark Mode transition for editor menus.
+
+15.6
+-----
+* [***] Block Editor: Fixed empty text fields on RTL layout. Now they are selectable and placeholders are visible.
+* [**] Block Editor: Add settings to allow changing column widths
+* [**] Block Editor: Media editing support in Gallery block.
+* [**] Updated UI when logging in with a Site Address.
+* [**] Updated UI when logging in/signing up with Apple.
+* [**] Updated UI when logging in/signing up with Google.
+* [**] Simplified Google authentication. If signup is attempted with an existing WordPress account, automatically redirects to login. If login is attempted without a matching WordPress account, automatically redirects to signup.
+* [**] Fixes issue where the stats were not updating when switching between sites in My Sites.
+* [*] Block Editor: Improved logic for creating undo levels.
+* [*] Social account login: Fixed an issue that could have inadvertently linked two social accounts.
+
+15.5
+-----
+* [*] Reader: revamped UI for your site header.
+* [***] Block Editor: New feature for WordPress.com and Jetpack sites: auto-complete username mentions. An auto-complete popup will show up when the user types the @ character in the block editor.
+* [*] Block Editor: Media editing support in Cover block.
+* [*] Block Editor: Fixed a bug on the Heading block, where a heading with a link and string formatting showed a white shadow in dark mode.
+
+15.4
+-----
+ * [**] Fixes issue where the new page editor wouldn't always show when selected from the "My Site" page on iOS versions 12.4 and below.
+ * [***] Block Editor: Media editing support in Media & Text block.
+ * [***] Block Editor: New block: Social Icons
+ * [*] Block Editor: Cover block placeholder is updated to allow users to start the block with a background color
+ * [**] Improved support for the Classic block to give folks a smooth transition from the classic editor to the block editor
+
+15.3
+-----
+* [***] Block Editor: Adds Copy, Cut, Paste, and Duplicate functionality to blocks
+* [***] Block Editor: Users can now individually edit unsupported blocks found in posts or pages. Not available on selfhosted sites or sites defaulting to classic editor.
+* [*] Block Editor: Improved editor loading experience with Ghost Effect.
+
+15.2
+----
+* [*] Block editor: Display content metrics information (blocks, words, characters count).
+* [*] Fixed a crash that results in navigating to the block editor quickly after logging out and immediately back in.
+* [***] Reader content improved: a lot of fixes in how the content appears when you're reading a post.
+* [**] A site's title can now be changed by tapping on the title in the site detail screen.
+* [**] Added a new Quick Start task to set a title for a new site.
+* [**] Block editor: Add support for customizing gradient type and angle in Buttons and Cover blocks.
+
+-----
+
+15.1
+-----
+* [**] Block Editor: Add support to upload videos to Cover Blocks after the editor has closed.
+* [*] Block Editor: Display the animation of animated GIFs while editing image blocks.
+* [**] Block editor: Adds support for theme colors and gradients.
+* [*] App Settings: Added an app-level toggle for light or dark appearance.
+* [*] Fix a bug where the Latest Post date on Insights Stats was being calculated incorrectly.
+* Block editor: [*] Support for breaking out of captions/citation authors by pressing enter on the following blocks: image, video, gallery, quote, and pullquote.
+* Block editor: [**] Adds editor support for theme defined colors and theme defined gradients on cover and button blocks.
+* [*] Fixed a bug where "Follow another site" was using the wrong steps in the "Grow Your Audience" Quick Start tour.
+* [*] Fix a bug where Quick Start completed tasks were not communicated to VoiceOver users.
+* [**] Quick Start: added VoiceOver support to the Next Steps section.
+* [*] Fixed a bug where the "Publish a post" Quick Start tour didn't reflect the app's new information architecture
+* [***] Free GIFs can now be added to the media library, posts, and pages.
+* [**] You can now set pages as your site's homepage or posts page directly from the Pages list.
+* [**] Fixed a bug that prevented some logins via 'Continue with Apple'.
+* [**] Reader: Fixed a bug where tapping on the more menu may not present the menu
+* [*] Block editor: Fix 'Take a Photo' option failing after adding an image to gallery block
+
+15.0
+-----
+* [**] Block editor: Fix media upload progress when there's no connection.
+* [*] Fix a bug where taking a photo for your user gravatar got you blocked in the crop screen.
+* Reader: Updated card design
+* [internal] Logging in via 'Continue with Google' has changes that can cause regressions. See https://git.io/Jf2LF for full testing details.
+* [***] Block Editor: New block: Verse
+* [***] Block Editor: Trash icon that is used to remove blocks is moved to the new menu reachable via ellipsis button in the block toolbar
+* [**] Block Editor: Add support for changing overlay color settings in Cover block
+* [**] Block Editor: Add enter/exit animation in FloatingToolbar
+* [**] Block Editor: Block toolbar can now collapse when the block width is smaller than the toolbar content
+* [**] Block Editor: Tooltip for page template selection buttons
+* [*] Block Editor: Fix merging of text blocks when text had active formatting (bold, italic, strike, link)
+* [*] Block Editor: Fix button alignment in page templates and make strings consistent
+* [*] Block Editor: Add support for displaying radial gradients in Buttons and Cover blocks
+* [*] Block Editor: Fix a bug where it was not possible to add a second image after previewing a post
+* [internal] Signing up via 'Continue with Google' has changes that can cause regressions. See https://git.io/JfwjX for full testing details.
+* My Site: Add support for setting the Homepage and Posts Page for a site.
+
+14.9
+-----
+* Streamlined navigation: now there are fewer and better organized tabs, posting shortcuts and more, so you can find what you need fast.
+* My Site: the "Add Posts and Pages" features has been moved. There is a new "Floating Action Button" in "My Site" that lets you create a new post or page without having to navigate to another screen.
+* My Site: the "Me" section has been moved. There is a new button on the top right of "My Site" that lets you access the "Me" section from there.
+* Reader: revamped UI with a tab bar that lets you quickly switch between sections, and filtering and settings panes to easily access and manage your favorite content.
+* [internal] the "Change Username" on the Signup Epilogue screen has navigation changes that can cause regressions. See https://git.io/JfGnv for testing details.
+* [internal] the "3 button view" (WP.com email, Google, SIWA, Site Address) presented after pressing the "Log In" button has navigation changes that can cause regressions. See https://git.io/JfZUV for testing details.
+* [**] Support the superscript and subscript HTML formatting on the Block Editor and Classic Editor.
+* [**] Block editor: Support for the pullquote block.
+* [**] Block editor: Fix the icons and buttons in Gallery, Paragraph, List and MediaText block on RTL mode.
+* [**] Block editor: Update page templates to use new blocks.
+* [**] Block editor: Fix a crash when uploading new videos on a video block.
+* [**] Block Editor: Add support for changing background and text color in Buttons block
+* [internal] the "enter your password" screen has navigation changes that can cause regressions. See https://git.io/Jfl1C for full testing details.
+* Support the superscript and subscript HTML formatting on the Block Editor and Classic Editor.
+* [***] You can now draw on images to annotate them using the Edit image feature in the post editor.
+* [*] Fixed a bug on the editors where changing a featured image didn't trigger that the post/page changed.
+
+14.8.1
+-----
+* Fix adding and removing of featured images to posts.
+
+14.8
+-----
+* Block editor: Prefill caption for image blocks when available on the Media library
+* Block editor: New block: Buttons. From now you’ll be able to add the individual Button block only inside the Buttons block
+* Block editor: Fix bug where whitespaces at start of text blocks were being removed
+* Block editor: Add support for upload options in Cover block
+* Block editor: Floating toolbar, previously located above nested blocks, is now placed at the bottom of the screen
+* Block editor: Fix the icons in FloatingToolbar on RTL mode
+* Block editor: Fix Quote block so it visually reflects selected alignment
+* Block editor: Fix bug where buttons in page templates were not rendering correctly on web
+* Block editor: Remove Subscription Button from the Blog template since it didn't have an initial functionality and it is hard to configure for users.
+* [internal] the "send magic link" screen has navigation changes that can cause regressions. See https://git.io/Jfqiz for testing details.
+* Updated UI for Login and Signup epilogues.
+* Fixes delayed split view resizing while rotating your device.
+
+14.7
+-----
+* Classic Editor: Fixed action sheet position for additional Media sources picker on iPad
+* [internal] the signup flow using email has code changes that can cause regressions. See https://git.io/JvALZ for testing details.
+* [internal] Notifications tab should pop to the root of the navigation stack when tapping on the tab from within a notification detail screen. See https://git.io/Jvxka for testing details.
+* Classic and Block editor: Prefill caption for image blocks when available on the Media library.
+* [internal] the "login by email" flow and the self-hosted login flow have code changes that can cause regressions. See https://git.io/JfeFN for testing details.
+* Block editor: Disable ripple effect in all BottomSheet's controls.
+* Block editor: New block: Columns
+* Block editor: New starter page template: Blog
+* Block editor: Make Starter Page Template picker buttons visible only when the screen height is enough
+* Block editor: Fix a bug which caused to show URL settings modal randomly when changing the device orientation multiple times during the time Starter Page Template Preview is open
+* [internal] the login by email flow and the self-hosted login flow have code changes that can cause regressions. See https://git.io/JfeFN for testing details.
+* Updated the appearance of the login and signup buttons to make signup more prominent.
+* [internal] the navigation to the "login by site address" flow has code changes that can cause regressions. See https://git.io/JfvP9 for testing details.
+* Updated site details screen title to My Site, to avoid duplicating the title of the current site which is displayed in the screen's header area.
+* You can now schedule your post, add tags or change the visibility before hitting "Publish Now" — and you don't have to go to the Post Settings for this!
+
+* Login Epilogue: fixed issue where account information never stopped loading for some self-hosted sites.
+* Updated site details screen title to My Site, to avoid duplicating the title of the current site which is displayed in the screen's header area.
+
+14.6
+-----
+* [internal] the login flow with 2-factor authentication enabled has code changes that can cause regressions. See https://git.io/Jvdil for testing details.
+* [internal] the login and signup Magic Link flows have code changes that could cause regressions. See https://git.io/JvSD6 and https://git.io/Jvy4P for testing details.
+* [internal] the login and signup Magic Link flows have code changes that can cause regressions. See https://git.io/Jvy4P for testing details.
+* [internal] the login and signup Continue with Google flows have code changes that can cause regressions. See https://git.io/JvypB for testing details.
+* Notifications: Fix layout on screens with a notch.
+* Post Commenting: fixed issue that prevented selecting an @ mention suggestion.
+* Fixed an issue that could have caused the app to crash when accessing Site Pages.
+* Site Creation: faster site creation, removed intermediate steps. Just select what kind of site you'd like, enter the domain name and the site will be created.
+* Post Preview: Increase Post and Page Preview size on iPads running iOS 13.
+* Block editor: Added the Cover block
+* Block editor: Removed the dimming effect on unselected blocks
+* Block editor: Add alignment options for Heading block
+* Block editor: Implemented dropdown toolbar for alignment toolbar in Heading, Paragraph, Image, MediaText blocks
+* Block Editor: When editing link settings, tapping the keyboard return button now closes the settings panel as well as closing the keyboard.
+* Fixed a crash when a blog's URL became `nil` from a Core Data operation.
+* Added Share action to the more menu in the Posts list
+* Period Stats: fix colors when switching between light and dark modes.
+* Media uploads from "Other Apps": Fixed an issue where the Cancel button on the document picker/browser was not showing up in Light Mode.
+* Fix a crash when accessing Blog Posts from the Quick Actions button on iPads running iOS 12 and below.
+* Reader post detail: fix colors when switching between light and dark modes.
+* Fixed an issue where Continue with Apple button wouldn't respond after Jetpack Setup > Sign up flow completed.
+
+
+14.5
+-----
+* Block editor: New block: Latest Posts
+* Block editor: Fix Quote block's left border not being visible in Dark Mode
+* Block editor: Added Starter Page Templates: when you create a new page, we now show you a few templates to get started more quickly.
+* Block editor: Fix crash when pasting HTML content with embeded images on paragraphs
+* Post Settings: Fix issue where the status of a post showed "Scheduled" instead of "Published" after scheduling before the current date.
+* Stats: Fix background color in Dark Mode on wider screen sizes.
+* Post Settings: Fix issue where the calendar selection may not match the selected date when site timezone differs from device timezone.
+* Dark Mode fixes:
+ - Border color on Search bars.
+ - Stats background color on wider screen sizes.
+ - Media Picker action bar background color.
+ - Login and Signup button colors.
+ - Reader comments colors.
+ - Jetpack install flow colors.
+* Reader: Fix toolbar and search bar width on wider screen sizes.
+* Updated the Signup and Login Magic Link confirmation screen advising the user to check their spam/junk folder.
+* Updated appearance of Google login/signup button.
+* Updated appearance of Apple login/signup button.
+
+14.4.1
+-----
+* Block Editor: Fix crash when inserting a Button Block.
+
14.4
-----
* Post Settings: Fixes the displayed publish date of posts which are to be immediately published.
-
+
14.3
-----
* Aztec and Block Editor: Fix the presentation of ordered lists with large numbers.
@@ -18,8 +959,8 @@
* Block editor: Add support for upload options in Gallery block
* Aztec and Block Editor: Fix the presentation of ordered lists with large numbers.
* Added Quick Action buttons on the Site Details page to access the most frequently used parts of a site.
-* Post Settings: Adjusts the weekday symbols in the calendar depending on Regional settings.
-
+* Post Settings: Adjusts the weekday symbols in the calendar depending on Regional settings.
+
14.2
-----
diff --git a/Rakefile b/Rakefile
index 967b4a881ffc..9986fb976ae6 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,222 +1,249 @@
-SWIFTLINT_VERSION="0.27.0"
-XCODE_WORKSPACE="WordPress.xcworkspace"
-XCODE_SCHEME="WordPress"
-XCODE_CONFIGURATION="Debug"
+# frozen_string_literal: true
+require 'English'
require 'fileutils'
require 'tmpdir'
require 'rake/clean'
require 'yaml'
require 'digest'
-PROJECT_DIR = File.expand_path(File.dirname(__FILE__))
+
+RUBY_REPO_VERSION = File.read('./.ruby-version').rstrip
+XCODE_WORKSPACE = 'WordPress.xcworkspace'
+XCODE_SCHEME = 'WordPress'
+XCODE_CONFIGURATION = 'Debug'
+EXPECTED_XCODE_VERSION = File.read('.xcode-version').rstrip
+
+PROJECT_DIR = __dir__
+abort('Project directory contains one or more spaces – unable to continue.') if PROJECT_DIR.include?(' ')
+
+SWIFTLINT_BIN = File.join(PROJECT_DIR, 'Pods', 'SwiftLint', 'swiftlint')
task default: %w[test]
-desc "Install required dependencies"
-task :dependencies => %w[dependencies:check assets:check]
+desc 'Install required dependencies'
+task dependencies: %w[dependencies:check assets:check]
namespace :dependencies do
- task :check => %w[bundler:check bundle:check credentials:apply pod:check lint:check]
+ task check: %w[ruby:check bundler:check bundle:check credentials:apply pod:check lint:check]
- namespace :bundler do
+ namespace :ruby do
task :check do
- unless command?("bundler")
- Rake::Task["dependencies:bundler:install"].invoke
+ unless ruby_version_is_match?
+ # show a warning that Ruby doesn't match .ruby-version
+ puts '====================================================================================='
+ puts 'Warning: Your local Ruby version doesn\'t match .ruby-version'
+ puts ''
+ puts ".ruby-version:\t#{RUBY_REPO_VERSION}"
+ puts "Your Ruby:\t#{RUBY_VERSION}"
+ puts ''
+ puts 'Refer to the WPiOS docs on setting the exact version with rbenv.'
+ puts ''
+ puts 'Press enter to continue anyway'
+ puts '====================================================================================='
+ $stdin.gets.strip
end
end
+ # compare repo Ruby version to local
+ def ruby_version_is_match?
+ RUBY_REPO_VERSION == RUBY_VERSION
+ end
+ end
+
+ namespace :bundler do
+ task :check do
+ Rake::Task['dependencies:bundler:install'].invoke unless command?('bundler')
+ end
+
task :install do
- puts "Bundler not found in PATH, installing to vendor"
+ puts 'Bundler not found in PATH, installing to vendor'
ENV['GEM_HOME'] = File.join(PROJECT_DIR, 'vendor', 'gems')
- ENV['PATH'] = File.join(PROJECT_DIR, 'vendor', 'gems', 'bin') + ":#{ENV['PATH']}"
- sh "gem install bundler" unless command?("bundler")
+ ENV['PATH'] = File.join(PROJECT_DIR, 'vendor', 'gems', 'bin') + ":#{ENV.fetch('PATH', nil)}"
+ sh 'gem install bundler' unless command?('bundler')
end
- CLOBBER << "vendor/gems"
+ CLOBBER << 'vendor/gems'
end
namespace :bundle do
task :check do
- sh "bundle check --path=${BUNDLE_PATH:-vendor/bundle} > /dev/null", verbose: false do |ok, res|
+ sh 'bundle check > /dev/null', verbose: false do |ok, _res|
next if ok
+
# bundle check exits with a non zero code if install is needed
- dependency_failed("Bundler")
- Rake::Task["dependencies:bundle:install"].invoke
+ dependency_failed('Bundler')
+ Rake::Task['dependencies:bundle:install'].invoke
end
end
task :install do
- fold("install.bundler") do
- sh "bundle install --jobs=3 --retry=3 --path=${BUNDLE_PATH:-vendor/bundle}"
+ fold('install.bundler') do
+ sh 'bundle install --jobs=3 --retry=3 --path=${BUNDLE_PATH:-vendor/bundle}'
end
end
- CLOBBER << "vendor/bundle"
- CLOBBER << ".bundle"
+ CLOBBER << 'vendor/bundle'
+ CLOBBER << '.bundle'
end
namespace :credentials do
task :apply do
next unless Dir.exist?(File.join(Dir.home, '.mobile-secrets/.git')) || ENV.key?('CONFIGURE_ENCRYPTION_KEY')
- sh('FASTLANE_SKIP_UPDATE_CHECK=1 FASTLANE_ENV_PRINTER=1 bundle exec fastlane run configure_apply force:true')
+
+ # The string is indented all the way to the left to avoid padding when printed in the terminal
+ command = %(
+FASTLANE_SKIP_UPDATE_CHECK=1 \
+FASTLANE_HIDE_CHANGELOG=1 \
+FASTLANE_HIDE_PLUGINS_TABLE=1 \
+FASTLANE_ENV_PRINTER=1 \
+FASTLANE_SKIP_ACTION_SUMMARY=1 \
+FASTLANE_HIDE_TIMESTAMP=1 \
+bundle exec fastlane run configure_apply force:true
+ )
+
+ sh(command)
end
end
namespace :pod do
task :check do
unless podfile_locked? && lockfiles_match?
- dependency_failed("CocoaPods")
- Rake::Task["dependencies:pod:install"].invoke
+ dependency_failed('CocoaPods')
+ Rake::Task['dependencies:pod:install'].invoke
end
end
task :install do
- fold("install.cocoapds") do
+ fold('install.cocoapds') do
+ pod %w[install]
+ rescue StandardError
+ puts "Attempting to fix Gutenberg-Mobile local podspecs failing to install — since that is one of the most common reason for `pod install` to fail — then retrying…\n\n"
+ Rake::Task['dependencies:pod:fix_gbm_pods'].invoke
pod %w[install]
end
end
+ task :fix_gbm_pods do
+ require 'yaml'
+
+ deps = YAML.load_file('Podfile.lock')['DEPENDENCIES']
+ gbm_pod_regex = %r{(.*) \(from `https://raw\.githubusercontent\.com/wordpress-mobile/gutenberg-mobile/.*/third-party-podspecs/.*\.podspec\.json`\)}.freeze
+ gbm_pods = deps.map do |pod|
+ gbm_pod_regex.match(pod)&.captures&.first
+ end.compact
+
+ pod ['update', *gbm_pods]
+ end
+
task :clean do
- fold("clean.cocoapds") do
+ fold('clean.cocoapds') do
FileUtils.rm_rf('Pods')
end
end
- CLOBBER << "Pods"
+ CLOBBER << 'Pods'
end
namespace :lint do
-
task :check do
if swiftlint_needs_install
- dependency_failed("SwiftLint")
- Rake::Task["dependencies:lint:install"].invoke
+ dependency_failed('SwiftLint')
+ Rake::Task['dependencies:pod:install'].invoke
end
end
-
- task :install do
- fold("install.swiftlint") do
- puts "Installing SwiftLint #{SWIFTLINT_VERSION} into #{swiftlint_path}"
- Dir.mktmpdir do |tmpdir|
- # Try first using a binary release
- zipfile = "#{tmpdir}/swiftlint-#{SWIFTLINT_VERSION}.zip"
- sh "curl --fail --location -o #{zipfile} https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/portable_swiftlint.zip || true"
- if File.exists?(zipfile)
- extracted_dir = "#{tmpdir}/swiftlint-#{SWIFTLINT_VERSION}"
- sh "unzip #{zipfile} -d #{extracted_dir}"
- FileUtils.mkdir_p("#{swiftlint_path}/bin")
- FileUtils.cp("#{extracted_dir}/swiftlint", "#{swiftlint_path}/bin/swiftlint")
- else
- sh "git clone --quiet https://github.com/realm/SwiftLint.git #{tmpdir}"
- Dir.chdir(tmpdir) do
- sh "git checkout --quiet #{SWIFTLINT_VERSION}"
- sh "git submodule --quiet update --init --recursive"
- FileUtils.remove_entry_secure(swiftlint_path) if Dir.exist?(swiftlint_path)
- FileUtils.mkdir_p(swiftlint_path)
- sh "make prefix_install PREFIX='#{swiftlint_path}'"
- end
- end
- end
- end
- end
- CLOBBER << "vendor/swiftlint"
end
-
end
namespace :assets do
task :check do
next unless Dir['WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/*.png'].empty?
+
Dir.mktmpdir do |tmpdir|
- puts "Generate internal icon set"
- if system("export PROJECT_DIR=#{Dir.pwd}/WordPress && export TEMP_DIR=#{tmpdir} && ./Scripts/BuildPhases/AddVersionToIcons.sh >/dev/null 2>&1") != 0
+ puts 'Generate internal icon set'
+ if system("export PROJECT_DIR=#{Dir.pwd}/WordPress && export TEMP_DIR=#{tmpdir} && ./Scripts/BuildPhases/AddVersionToIcons.sh >/dev/null 2>&1") != 0
system("cp #{Dir.pwd}/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/*.png #{Dir.pwd}/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/")
end
- end
+ end
end
-end
+end
-CLOBBER << "vendor"
+CLOBBER << 'vendor'
-desc "Mocks"
+desc 'Mocks'
task :mocks do
- wordpress_mocks_path = "./Pods/WordPressMocks"
- # If WordPressMocks is referenced by a local path, use that.
- unless lockfile_hash.dig("EXTERNAL SOURCES", "WordPressMocks", :path).nil?
- wordpress_mocks_path = lockfile_hash.dig("EXTERNAL SOURCES", "WordPressMocks", :path)
- end
-
- sh "#{wordpress_mocks_path}/scripts/start.sh 8282"
+ sh "#{File.join(PROJECT_DIR, 'API-Mocks', 'scripts', 'start.sh')} 8282"
end
desc "Build #{XCODE_SCHEME}"
-task :build => [:dependencies] do
+task build: [:dependencies] do
xcodebuild(:build)
end
desc "Profile build #{XCODE_SCHEME}"
-task :buildprofile => [:dependencies] do
- ENV["verbose"] = "1"
+task buildprofile: [:dependencies] do
+ ENV['verbose'] = '1'
xcodebuild(:build, "OTHER_SWIFT_FLAGS='-Xfrontend -debug-time-compilation -Xfrontend -debug-time-expression-type-checking'")
end
-task :timed_build => [:clean] do
+task timed_build: [:clean] do
require 'benchmark'
time = Benchmark.measure do
- Rake::Task["build"].invoke
+ Rake::Task['build'].invoke
end
puts "CPU Time: #{time.total}"
puts "Wall Time: #{time.real}"
end
-desc "Run test suite"
-task :test => [:dependencies] do
+desc 'Run test suite'
+task test: [:dependencies] do
xcodebuild(:build, :test)
end
-desc "Remove any temporary products"
+desc 'Remove any temporary products'
task :clean do
xcodebuild(:clean)
end
-desc "Checks the source for style errors"
-task :lint => %w[dependencies:lint:check] do
+desc 'Checks the source for style errors'
+task lint: %w[dependencies:lint:check] do
swiftlint %w[lint --quiet]
end
namespace :lint do
- desc "Automatically corrects style errors where possible"
- task :autocorrect => %w[dependencies:lint:check] do
- swiftlint %w[autocorrect]
+ desc 'Automatically corrects style errors where possible'
+ task autocorrect: %w[dependencies:lint:check] do
+ swiftlint %w[lint --autocorrect --quiet]
end
end
namespace :git do
hooks = %w[pre-commit post-checkout post-merge]
- desc "Install git hooks"
+ desc 'Install git hooks'
task :install_hooks do
hooks.each do |hook|
target = hook_target(hook)
source = hook_source(hook)
backup = hook_backup(hook)
- next if File.symlink?(target) and File.readlink(target) == source
- next if File.file?(target) and File.identical?(target, source)
+ next if File.symlink?(target) && (File.readlink(target) == source)
+ next if File.file?(target) && File.identical?(target, source)
+
if File.exist?(target)
puts "Existing hook for #{hook}. Creating backup at #{target} -> #{backup}"
- FileUtils.mv(target, backup, :force => true)
+ FileUtils.mv(target, backup, force: true)
end
FileUtils.ln_s(source, target)
puts "Installed #{hook} hook"
end
end
- desc "Uninstall git hooks"
+ desc 'Uninstall git hooks'
task :uninstall_hooks do
hooks.each do |hook|
target = hook_target(hook)
source = hook_source(hook)
backup = hook_backup(hook)
- next unless File.symlink?(target) and File.readlink(target) == source
+ next unless File.symlink?(target) && (File.readlink(target) == source)
+
puts "Removing hook for #{hook}"
File.unlink(target)
if File.exist?(backup)
@@ -227,11 +254,12 @@ namespace :git do
end
def hook_target(hook)
- ".git/hooks/#{hook}"
+ hooks_dir = `git rev-parse --git-path hooks`.chomp
+ File.join(hooks_dir, hook)
end
def hook_source(hook)
- "../../Scripts/hooks/#{hook}"
+ File.absolute_path(File.join(PROJECT_DIR, 'Scripts', 'hooks', hook))
end
def hook_backup(hook)
@@ -240,12 +268,10 @@ namespace :git do
end
namespace :git do
- task :pre_commit => %[dependencies:lint:check] do
- begin
- swiftlint %w[lint --quiet --strict]
- rescue
- exit $?.exitstatus
- end
+ task pre_commit: %(dependencies:lint:check) do
+ swiftlint %w[lint --quiet --strict]
+ rescue StandardError
+ exit $CHILD_STATUS.exitstatus
end
task :post_merge do
@@ -257,19 +283,377 @@ namespace :git do
end
end
-desc "Open the project in Xcode"
-task :xcode => [:dependencies] do
+desc 'Open the project in Xcode'
+task xcode: [:dependencies] do
sh "open #{XCODE_WORKSPACE}"
end
-def fold(label, &block)
- puts "travis_fold:start:#{label}" if is_travis?
- yield
- puts "travis_fold:end:#{label}" if is_travis?
+desc 'Install and configure WordPress iOS and its dependencies - External Contributors'
+namespace :init do
+ task oss: %w[
+ install:xcode:check
+ dependencies
+ install:tools:check_oss
+ install:lint:check
+ credentials:setup
+ ]
+
+ desc 'Install and configure WordPress iOS and its dependencies - Automattic Developers'
+ task developer: %w[
+ install:xcode:check
+ dependencies
+ install:tools:check_developer
+ install:lint:check
+ credentials:setup
+ gpg_key:setup
+ ]
+end
+
+namespace :install do
+ namespace :xcode do
+ task check: %w[xcode_app:check xcode_select:check]
+
+ # xcode_app namespace checks for the existance of xcode on developer's machine,
+ # checks to make sure that developer is using the correct version per the CI specs
+ # and confirms developer has xcode-select command line tools, if not installs them
+ namespace :xcode_app do
+ # check the existance of xcode, and compare version to CI specs
+ task :check do
+ puts 'Checking for system for Xcode'
+ if xcode_installed?
+ puts 'Xcode installed'
+ else
+ # if xcode is not installed, prompt user to install and terminate rake
+ puts 'Xcode not Found!'
+ puts ''
+ puts '====================================================================================='
+ puts 'Developing for WordPressiOS requires Xcode.'
+ puts 'Please install Xcode before setting up WordPressiOS'
+ puts 'https://apps.apple.com/app/xcode/id497799835?mt=12'
+ abort('')
+ end
+
+ puts 'Checking CI recommended installed Xcode version'
+
+ unless xcode_version_is_correct?
+ # if xcode is the wrong version, prompt user to install the correct version and terminate rake
+ puts 'Not recommended version of Xcode installed'
+ puts "It is recommended to use Xcode version #{EXPECTED_XCODE_VERSION}"
+ puts 'Please press enter to continue'
+ $stdin.gets.strip
+ next
+ end
+ end
+
+ # Check if Xcode is installed
+ def xcode_installed?
+ system 'xcodebuild -version', %i[out err] => File::NULL
+ end
+
+ # compare xcode version to expected CI spec version
+ def xcode_version_is_correct?
+ if xcode_version == EXPECTED_XCODE_VERSION
+ puts 'Correct version of Xcode installed'
+ true
+ else
+ false
+ end
+ end
+
+ def xcode_version
+ puts 'Checking installed version of Xcode'
+ version = `xcodebuild -version`
+
+ version.split[1]
+ end
+ end
+
+ # Xcode-select command line tools must be installed to update dependencies
+ # Xcode_select checks the existence of xcode-select on developer's machine, installs if not found
+ namespace :xcode_select do
+ task :check do
+ puts 'Checking system for Xcode-select'
+ if command?('xcode-select')
+ puts 'Xcode-select installed'
+ else
+ Rake::Task['install:xcode:xcode_select:install'].invoke
+ end
+ end
+
+ task :install do
+ puts 'Installing xcode select'
+ sh 'xcode-select --install'
+ end
+ end
+ end
+
+ # Tools namespace deals with installing developer and OSS tools required to work on WPiOS
+ namespace :tools do
+ task check_oss: %w[homebrew:check addons:check_oss]
+ task check_developer: %w[homebrew:check addons:check_developer]
+
+ # Check for Homebrew and install if missing
+ namespace :homebrew do
+ task :check do
+ puts 'Checking system for Homebrew'
+ if command?('brew')
+ puts 'Homebrew installed'
+ else
+ Rake::Task['install:tools:homebrew:prompt'].invoke
+ end
+ end
+
+ # prompt developer that Homebrew is required to install required tools and confirm they want to install
+ # allow to bail out of install script if they developer declines to install homebrew
+ task :prompt do
+ puts '====================================================================================='
+ puts 'Setting WordPress iOS requires installing Homebrew to manage installing some tools'
+ puts 'For more information on Homebrew check out https://brew.sh/'
+ puts 'Do you want to continue with the WordPress iOS setup and install Homebrew?'
+ puts "Press 'Y' to install Homebrew. Press 'N' for exit"
+ puts '====================================================================================='
+
+ if display_prompt_response == true
+ Rake::Task['install:tools:homebrew:install'].invoke
+ else
+ abort('')
+ end
+ end
+
+ task :install do
+ command = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"'
+ sh command
+ end
+ end
+
+ # Install required tools to work with WPiOS
+ namespace :addons do
+ # NOTE: hash key = default installed directory on device
+ # hash value = brew install location
+ oss_tools = { 'convert' => 'imagemagick',
+ 'gs' => 'ghostscript' }
+ developer_tools = { 'convert' => 'imagemagick',
+ 'gs' => 'ghostscript',
+ 'sentry-cli' => 'getsentry/tools/sentry-cli',
+ 'gpg' => 'gpg',
+ 'git-crypt' => 'git-crypt' }
+
+ # Check for tool, install if not installed
+ task :check_oss do
+ tool_check(oss_tools)
+ end
+
+ task :check_developer do
+ tool_check(developer_tools)
+ end
+
+ # check if the developer tool is present in the machine, if not install
+ def tool_check(hash)
+ hash.each do |key, value|
+ puts "Checking system for #{key}"
+ if command?(key)
+ puts "#{key} found"
+ else
+ tool_install(value)
+ end
+ end
+ end
+
+ # install selected developer tool
+ def tool_install(tool)
+ puts "#{tool} not found. Installing #{tool}"
+ sh "brew install #{tool}"
+ end
+ end
+ end
+
+ namespace :lint do
+ task :check do
+ unless git_initialized?
+ puts 'Initializing git repository'
+ sh 'git init', verbose: false
+ end
+
+ Rake::Task['git:install_hooks'].invoke
+ end
+
+ def git_initialized?
+ sh 'git rev-parse --is-inside-work-tree > /dev/null 2>&1', verbose: false
+ end
+ end
+end
+
+# Credentials deals with the setting up the developer's WPCOM API app ID and app Secret
+namespace :credentials do
+ task setup: %w[credentials:prompt credentials:set_app_secrets]
+
+ task :prompt do
+ puts ''
+ puts '====================================================================================='
+ puts 'To be able to log into the WordPress app while developing you will need to setup API credentials'
+ puts 'To do this follow these steps'
+ puts ''
+ puts ''
+ puts ''
+ puts '====================================================================================='
+
+ puts "1. Go to https://wordpress.com/start/user and create a WordPress.com account (if you don't already have one)."
+ prompt_for_continue('Once you have created your account,')
+
+ puts '====================================================================================='
+ puts '2. Now register an API application at https://developer.wordpress.com/apps/.'
+ prompt_for_continue('Once you have registered your API App,')
+
+ puts '====================================================================================='
+ puts '3. Make sure to set "Redirect URLs"= https://localhost and "Type" = Native and click "Create" then "Update".'
+ prompt_for_continue('Once you have set the redirect url and type,')
+
+ puts '====================================================================================='
+ prompt_for_continue('Lastly, keep your Client ID and App Secret on hand for the next steps,')
+ end
+
+ def prompt_for_continue(prompt)
+ puts "#{prompt} Please press enter to continue"
+ $stdin.gets.strip
+ end
+
+ # user given app id and secret and create a new wpcom_app_credentials file
+ task :set_app_secrets do
+ set_app_secrets(client_id, client_secret)
+ end
+
+ def client_id
+ $stdout.puts 'Please enter your Client ID'
+ $stdin.gets.strip
+ end
+
+ def client_secret
+ $stdout.puts 'Please enter your Client Secret'
+ $stdin.gets.strip
+ end
+
+ # Duplicate the example file and add the new app secret and app id
+ def set_app_secrets(id, secret)
+ puts 'Writing App ID and App Secret to secrets file'
+
+ replaced_text = File.read('WordPress/Credentials/Secrets-example.swift')
+ .gsub('let client = "0"', "let client=\"#{id}\"")
+ .gsub('let secret = "your-secret-here"', "let secret=\"#{secret}\"")
+
+ File.open('WordPress/Credentials/Secrets.swift', 'w') do |file|
+ file.puts replaced_text
+ end
+ end
+end
+
+namespace :gpg_key do
+ # automate the process of creatong a GPG key
+ task setup: %w[gpg_key:check gpg_key:prompt gpg_key:finish]
+
+ # confirm that GPG tools is installed
+ task :check do
+ puts 'Checking system for GPG Tools'
+ if command?('gpg')
+ puts 'GPG Tools found'
+ else
+ Rake::Task['gpg_key:install'].invoke
+ end
+ end
+
+ # install GPG Tools
+ task :install do
+ puts 'GPG Tools not found. Installing GPG Tools'
+ sh 'brew install gpg'
+ end
+
+ # Ask developer if they need to create a new key.
+ # If yes, begin process of creating key, if no move on
+ task :prompt do
+ next unless create_gpg_key?
+
+ if create_default_key?
+ display_default_config_helpers
+ Rake::Task['gpg_key:generate_default'].invoke
+ else
+ Rake::Task['gpg_key:generate_custom'].invoke
+ end
+ end
+
+ # Generate new GPG key
+ task :generate_custom do
+ puts ''
+ puts 'Begin Generating Custom GPG Keys'
+ puts '====================================================================================='
+
+ sh 'gpg --full-generate-key', verbose: false
+ end
+
+ # Generate new default GPG key
+ task :generate_default do
+ puts ''
+ puts 'Begin Generating Default GPG Keys'
+ puts '====================================================================================='
+
+ sh 'gpg --generate-key', verbose: false
+ end
+
+ # prompt developer to send GPG key to Platform
+ task :finish do
+ puts '====================================================================================='
+ puts 'Key Generation Complete!'
+ puts 'Please send your GPG public key to Platform 9-3/4'
+ puts 'You can contact them in the Slack channel #platform9'
+ puts '====================================================================================='
+ end
+
+ # ask user if they want to create a key, loop till given a valid answer
+ def create_gpg_key?
+ puts '====================================================================================='
+ puts 'To access production credentials for the WordPress app you will need to a GPG Key'
+ puts 'Do you need to generate a new GPG Key?'
+ puts "Press 'Y' to create a new key. Press 'N' to skip"
+
+ display_prompt_response
+ end
+
+ # ask user if they want to create a key, loop till given a valid answer
+ def create_default_key?
+ puts '====================================================================================='
+ puts 'You can choose to setup with a default or custom key pair setup'
+ puts 'Default setup - Type: RSA to RSA, RSA length: 2048, Valid for: does not expire'
+ puts 'Would you like to continue with the default setup?'
+ puts '====================================================================================='
+ puts "Press 'Y' for Yes. Press 'N' for custom configuration"
+
+ display_prompt_response
+ end
+
+ # display prompt for developer to aid in setting up default key
+ def display_default_config_helpers
+ puts ''
+ puts ''
+ puts '====================================================================================='
+ puts 'You will need to enter the following info to create your key'
+ puts 'Please enter your real name, email address, and a password for your key when prompted'
+ puts '====================================================================================='
+ end
+end
+
+# prompt for a Y or N response, continue asking if other character
+# return true for Y and false for N
+def display_prompt_response
+ response = $stdin.gets.strip.upcase
+ until %w[Y N].include?(response)
+ puts 'Invalid entry, please enter Y or N'
+ response = $stdin.gets.strip.upcase
+ end
+
+ response == 'Y'
end
-def is_travis?
- return ENV["TRAVIS"] != nil
+# FIXME: This used to add Travis folding formatting, but we no longer use Travis. I'm leaving it here for the moment, but I think we should remove it.
+def fold(_)
+ yield
end
def pod(args)
@@ -278,7 +662,7 @@ def pod(args)
end
def lockfile_hash
- YAML.load(File.read("Podfile.lock"))
+ YAML.load_file('Podfile.lock')
end
def lockfiles_match?
@@ -286,67 +670,58 @@ def lockfiles_match?
end
def podfile_locked?
- podfile_checksum = Digest::SHA1.file("Podfile")
- lockfile_checksum = lockfile_hash["PODFILE CHECKSUM"]
+ podfile_checksum = Digest::SHA1.file('Podfile')
+ lockfile_checksum = lockfile_hash['PODFILE CHECKSUM']
podfile_checksum == lockfile_checksum
end
-def swiftlint_path
- "#{PROJECT_DIR}/vendor/swiftlint"
-end
-
def swiftlint(args)
- args = [swiftlint_bin] + args
+ args = [SWIFTLINT_BIN] + args
sh(*args)
end
-def swiftlint_bin
- "#{swiftlint_path}/bin/swiftlint"
-end
-
def swiftlint_needs_install
- return true unless File.exist?(swiftlint_bin)
- installed_version = `"#{swiftlint_bin}" version`.chomp
- return (installed_version != SWIFTLINT_VERSION)
+ File.exist?(SWIFTLINT_BIN) == false
end
def xcodebuild(*build_cmds)
- cmd = "xcodebuild"
+ cmd = 'xcodebuild'
cmd += " -destination 'platform=iOS Simulator,name=iPhone 6s'"
- cmd += " -sdk iphonesimulator"
+ cmd += ' -sdk iphonesimulator'
cmd += " -workspace #{XCODE_WORKSPACE}"
cmd += " -scheme #{XCODE_SCHEME}"
cmd += " -configuration #{xcode_configuration}"
- cmd += " "
- cmd += build_cmds.map(&:to_s).join(" ")
- cmd += " | bundle exec xcpretty -f `bundle exec xcpretty-travis-formatter` && exit ${PIPESTATUS[0]}" unless ENV['verbose']
+ cmd += ' '
+ cmd += build_cmds.map(&:to_s).join(' ')
+ cmd += ' | bundle exec xcpretty -f `bundle exec xcpretty-travis-formatter` && exit ${PIPESTATUS[0]}' unless ENV['verbose']
sh(cmd)
end
def xcode_configuration
- ENV['XCODE_CONFIGURATION'] || XCODE_CONFIGURATION
+ ENV.fetch('XCODE_CONFIGURATION') { XCODE_CONFIGURATION }
end
def command?(command)
system("which #{command} > /dev/null 2>&1")
end
+
def dependency_failed(component)
msg = "#{component} dependencies missing or outdated. "
if ENV['DRY_RUN']
- msg += "Run rake dependencies to install them."
- fail msg
+ msg += 'Run rake dependencies to install them.'
+ raise msg
else
- msg += "Installing..."
+ msg += 'Installing...'
puts msg
end
end
def check_dependencies_hook
- ENV['DRY_RUN'] = "1"
+ ENV['DRY_RUN'] = '1'
begin
Rake::Task['dependencies'].invoke
- rescue Exception => e
+ rescue StandardError => e
puts e.message
exit 1
end
diff --git a/Scripts/BuildPhases/GenerateCredentials.sh b/Scripts/BuildPhases/GenerateCredentials.sh
new file mode 100755
index 000000000000..9b94b0deae4c
--- /dev/null
+++ b/Scripts/BuildPhases/GenerateCredentials.sh
@@ -0,0 +1,130 @@
+#!/bin/bash -euo pipefail
+
+# The Secrets File Sources
+SECRETS_ROOT="${HOME}/.configure/wordpress-ios/secrets"
+
+# To help the Xcode build system optimize the build, we want to ensure each of
+# the secrets we want to copy is defined as an input file for the run script
+# build phase.
+#
+# > The Xcode Build System will use [these files] to determine if your run
+# > scripts should actually run or not. So this should include any file that
+# > your run script phase, the script content, is actually going to read or
+# > look at during its process.
+#
+# > If you have no input files declared, the Xcode build system will need to
+# > run your run script phase on every single build.
+#
+# https://developer.apple.com/videos/play/wwdc2018/408/
+function ensure_is_in_input_files_list() {
+ # Loop through the file input lists looking for $1. If not found, fail the
+ # build.
+ if [ -z "$1" ]; then
+ echo "error: Input file list verification needs a path to verify!"
+ exit 1
+ fi
+ file_to_find=$1
+
+ i=0
+ found=false
+ while [[ $i -lt $SCRIPT_INPUT_FILE_LIST_COUNT && "$found" = false ]]
+ do
+ # Need this two step process to access the input at index
+ file_list_resolved_var_name=SCRIPT_INPUT_FILE_LIST_${i}
+ # The following reads the processed xcfilelist line by line looking for
+ # the given file
+ while read input_file; do
+ if [ "$file_to_find" == "$input_file" ]; then
+ found=true
+ break
+ fi
+ done <"${!file_list_resolved_var_name}"
+ let i=i+1
+ done
+ if [ "$found" = false ]; then
+ echo "error: Could not find $file_to_find as an input to the build phase. Add $file_to_find to the input files list using the .xcfilelist."
+ exit 1
+ fi
+}
+
+PRODUCTION_SECRETS_FILE="${SECRETS_ROOT}/WordPress-Secrets.swift"
+ensure_is_in_input_files_list $PRODUCTION_SECRETS_FILE
+INTERNAL_SECRETS_FILE="${SECRETS_ROOT}/WordPress-Secrets-Internal.swift"
+ensure_is_in_input_files_list $INTERNAL_SECRETS_FILE
+ALPHA_SECRETS_FILE="${SECRETS_ROOT}/WordPress-Secrets-Alpha.swift"
+ensure_is_in_input_files_list $ALPHA_SECRETS_FILE
+JETPACK_SECRETS_FILE="${SECRETS_ROOT}/Jetpack-Secrets.swift"
+ensure_is_in_input_files_list $JETPACK_SECRETS_FILE
+
+LOCAL_SECRETS_FILE="${SRCROOT}/Credentials/Secrets.swift"
+EXAMPLE_SECRETS_FILE="${SRCROOT}/Credentials/Secrets-example.swift"
+ensure_is_in_input_files_list $EXAMPLE_SECRETS_FILE
+
+# The Secrets file destination
+SECRETS_DESTINATION_FILE="${BUILD_DIR}/Secrets/Secrets.swift"
+mkdir -p $(dirname "$SECRETS_DESTINATION_FILE")
+
+# If the WordPress Production Secrets are available for WordPress, use them
+if [ -f "$PRODUCTION_SECRETS_FILE" ] && [ "$BUILD_SCHEME" == "WordPress" ]; then
+ echo "Applying Production Secrets"
+ cp -v "$PRODUCTION_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}"
+ exit 0
+fi
+
+# If the WordPress Internal Secrets are available, use them
+if [ -f "$INTERNAL_SECRETS_FILE" ] && [ "${BUILD_SCHEME}" == "WordPress Internal" ]; then
+ echo "Applying Internal Secrets"
+ cp -v "$INTERNAL_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}"
+ exit 0
+fi
+
+# If the WordPress Alpha Secrets are available, use them
+if [ -f "$ALPHA_SECRETS_FILE" ] && [ "${BUILD_SCHEME}" == "WordPress Alpha" ]; then
+ echo "Applying Alpha Secrets"
+ cp -v "$ALPHA_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}"
+ exit 0
+fi
+
+# If the Jetpack Secrets are available (and if we're building Jetpack) use them
+if [ -f "$JETPACK_SECRETS_FILE" ] && [ "${BUILD_SCHEME}" == "Jetpack" ]; then
+ echo "Applying Jetpack Secrets"
+ cp -v "$JETPACK_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}"
+ exit 0
+fi
+
+EXTERNAL_CONTRIBUTOR_RELEASE_MSG="External contributors should not need to perform a Release build"
+
+# If the developer has a local secrets file, use it
+if [ -f "$LOCAL_SECRETS_FILE" ]; then
+ if [[ $CONFIGURATION == Release* ]]; then
+ echo "error: You can't do a Release build when using local Secrets (from $LOCAL_SECRETS_FILE). $EXTERNAL_CONTRIBUTOR_RELEASE_MSG."
+ exit 1
+ fi
+
+ echo "warning: Using local Secrets from $LOCAL_SECRETS_FILE. If you are an external contributor, this is expected and you can ignore this warning. If you are an internal contributor, make sure to use our shared credentials instead."
+ echo "Applying Local Secrets"
+ cp -v "$LOCAL_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}"
+ exit 0
+fi
+
+# None of the above secrets was found. Use the example secrets file as a last
+# resort, unless building for Release.
+
+COULD_NOT_FIND_SECRET_MSG="Could not find secrets file at ${SECRETS_DESTINATION_FILE}. This is likely due to the source secrets being missing from ${SECRETS_ROOT}"
+INTERNAL_CONTRIBUTOR_MSG="If you are an internal contributor, run \`bundle exec fastlane run configure_apply\` to update your secrets and try again"
+EXTERNAL_CONTRIBUTOR_MSG="If you are an external contributor, run \`bundle exec rake init:oss\` to set up and use your own credentials"
+
+case $CONFIGURATION in
+ Release*)
+ # There are three release configurations: Release, Release-Alpha, and
+ # Release-Internal. Since they all start with "Release" we can use a
+ # pattern to check for them.
+ echo "error: $COULD_NOT_FIND_SECRET_MSG. Cannot continue Release build. $INTERNAL_CONTRIBUTOR_MSG. $EXTERNAL_CONTRIBUTOR_RELEASE_MSG."
+ exit 1
+ ;;
+ *)
+ echo "warning: $COULD_NOT_FIND_SECRET_MSG. Falling back to $EXAMPLE_SECRETS_FILE. In a Release build, this would be an error. $INTERNAL_CONTRIBUTOR_MSG. $EXTERNAL_CONTRIBUTOR_MSG."
+ echo "Applying Example Secrets"
+ cp -v "$EXAMPLE_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}"
+ ;;
+esac
diff --git a/Scripts/BuildPhases/GenerateCredentials.xcfilelist b/Scripts/BuildPhases/GenerateCredentials.xcfilelist
new file mode 100644
index 000000000000..7c04a0f6df97
--- /dev/null
+++ b/Scripts/BuildPhases/GenerateCredentials.xcfilelist
@@ -0,0 +1,20 @@
+# Lists of input files for the script that populates the app's secrets with the
+# correct values for the current scheme and build configuration.
+${HOME}/.configure/wordpress-ios/secrets/WordPress-Secrets.swift
+${HOME}/.configure/wordpress-ios/secrets/WordPress-Secrets-Internal.swift
+${HOME}/.configure/wordpress-ios/secrets/WordPress-Secrets-Alpha.swift
+${HOME}/.configure/wordpress-ios/secrets/Jetpack-Secrets.swift
+
+# Local Secrets file that external contributors can use to specify their own
+# ClientID and Secrets. This file is created by the Rakefile when external
+# contributors run the `init:oss` task and provide their own credentials.
+${SRCROOT}/Credentials/Secrets.swift
+
+# Example secrets file, we fallback to this if none of the above is avaiable.
+# That usually happens on new machines, to external contributors, or in CI
+# builds that don't need access to secrets, such as the unit tests.
+${SRCROOT}/Credentials/Secrets-example.swift
+
+# Add the script that uses this file as a source, so that, if the script
+# changes, Xcode will run it again on the next build.
+${SRCROOT}/../Scripts/BuildPhases/ApplyConfiguration.sh
diff --git a/Scripts/BuildPhases/LintAppLocalizedStringsUsage.sh b/Scripts/BuildPhases/LintAppLocalizedStringsUsage.sh
new file mode 100755
index 000000000000..9d72f0876452
--- /dev/null
+++ b/Scripts/BuildPhases/LintAppLocalizedStringsUsage.sh
@@ -0,0 +1,21 @@
+#!/bin/bash -eu
+
+SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
+SCRIPT_SRC="${SCRIPT_DIR}/LintAppLocalizedStringsUsage.swift"
+
+LINTER_BUILD_DIR="${BUILD_DIR:-${TMPDIR}}"
+LINTER_EXEC="${LINTER_BUILD_DIR}/$(basename "${SCRIPT_SRC}" .swift)"
+
+if [ ! -x "${LINTER_EXEC}" ] || ! (shasum -c "${LINTER_EXEC}.shasum" >/dev/null 2>/dev/null); then
+ echo "Pre-compiling linter script to ${LINTER_EXEC}..."
+ swiftc -O -sdk "$(xcrun --sdk macosx --show-sdk-path)" "${SCRIPT_SRC}" -o "${LINTER_EXEC}"
+ shasum "${SCRIPT_SRC}" >"${LINTER_EXEC}.shasum"
+ chmod +x "${LINTER_EXEC}"
+ echo "Pre-compiled linter script ready"
+fi
+
+if [ -z "${PROJECT_FILE_PATH:=${1:-}}" ]; then
+ echo "error: Please provide the path to the xcodeproj to scan"
+ exit 1
+fi
+"$LINTER_EXEC" "${PROJECT_FILE_PATH}" "${@:2}"
diff --git a/Scripts/BuildPhases/LintAppLocalizedStringsUsage.swift b/Scripts/BuildPhases/LintAppLocalizedStringsUsage.swift
new file mode 100755
index 000000000000..9a29840bd00a
--- /dev/null
+++ b/Scripts/BuildPhases/LintAppLocalizedStringsUsage.swift
@@ -0,0 +1,326 @@
+import Foundation
+
+// MARK: Xcodeproj entry point type
+
+/// The main entry point type to parse `.xcodeproj` files
+class Xcodeproj {
+ let projectURL: URL // points to the "/.xcodeproj/project.pbxproj" file
+ private let pbxproj: PBXProjFile
+
+ /// Semantic type for strings that correspond to an object' UUID in the `pbxproj` file
+ typealias ObjectUUID = String
+
+ /// Builds an `Xcodeproj` instance by parsing the `.xcodeproj` or `.pbxproj` file at the provided URL.
+ init(url: URL) throws {
+ projectURL = url.pathExtension == "xcodeproj" ? URL(fileURLWithPath: "project.pbxproj", relativeTo: url) : url
+ let data = try Data(contentsOf: projectURL)
+ let decoder = PropertyListDecoder()
+ pbxproj = try decoder.decode(PBXProjFile.self, from: data)
+ }
+
+ /// An internal mapping listing the parent ObjectUUID for each ObjectUUID.
+ /// - Built by recursing top-to-bottom in the various `PBXGroup` objects of the project to visit all the children objects,
+ /// and storing which parent object they belong to.
+ /// - Used by the `resolveURL` method to find the real path of a `PBXReference`, as we need to navigate from the `PBXReference` object
+ /// up into the chain of parent `PBXGroup` containers to construct the successive relative paths of groups using `sourceTree = ""`
+ private lazy var referrers: [ObjectUUID: ObjectUUID] = {
+ var referrers: [ObjectUUID: ObjectUUID] = [:]
+ func recurseIfGroup(objectID: ObjectUUID) {
+ guard let group = try? (self.pbxproj.object(id: objectID) as PBXGroup) else { return }
+ for childID in group.children {
+ referrers[childID] = objectID
+ recurseIfGroup(objectID: childID)
+ }
+ }
+ recurseIfGroup(objectID: self.pbxproj.rootProject.mainGroup)
+ return referrers
+ }()
+}
+
+// Convenience methods and properties
+extension Xcodeproj {
+ /// Builds an `Xcodeproj` instance by parsing the `.xcodeproj` or `pbxproj` file at the provided path
+ convenience init(path: String) throws {
+ try self.init(url: URL(fileURLWithPath: path))
+ }
+
+ /// The directory where the `.xcodeproj` resides.
+ var projectDirectory: URL { projectURL.deletingLastPathComponent().deletingLastPathComponent() }
+ /// The list of `PBXNativeTarget` targets in the project. Convenience getter for `PBXProjFile.nativeTargets`
+ var nativeTargets: [PBXNativeTarget] { pbxproj.nativeTargets }
+ /// The list of `PBXBuildFile` files a given `PBXNativeTarget` will build. Convenience getter for `PBXProjFile.buildFiles(for:)`
+ func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] { pbxproj.buildFiles(for: target) }
+
+ /// Finds the full path / URL of a `PBXBuildFile` based on the groups it belongs to and their `sourceTree` attribute
+ func resolveURL(to buildFile: PBXBuildFile) throws -> URL? {
+ if let fileRefID = buildFile.fileRef, let fileRefObject = try? self.pbxproj.object(id: fileRefID) as PBXFileReference {
+ return try resolveURL(objectUUID: fileRefID, object: fileRefObject)
+ } else {
+ // If the `PBXBuildFile` is pointing to `XCVersionGroup` (like `*.xcdatamodel`) and `PBXVariantGroup` (like `*.strings`)
+ // (instead of a `PBXFileReference`), then in practice each file in the group's `children` will be built by the Build Phase.
+ // In practice we can skip parsing those in our case and save some CPU, as we don't have a need to lint those non-source-code files.
+ return nil // just skip those (but don't throw — those are valid use cases in any pbxproj, just ones we don't care about)
+ }
+ }
+
+ /// Finds the full path / URL of a PBXReference (`PBXFileReference` of `PBXGroup`) based on the groups it belongs to and their `sourceTree` attribute
+ private func resolveURL(objectUUID: ObjectUUID, object: T) throws -> URL? {
+ if objectUUID == self.pbxproj.rootProject.mainGroup { return URL(fileURLWithPath: ".", relativeTo: projectDirectory) }
+
+ switch object.sourceTree {
+ case .absolute:
+ guard let path = object.path else { throw ProjectInconsistencyError.incorrectAbsolutePath(id: objectUUID) }
+ return URL(fileURLWithPath: path)
+ case .group:
+ guard let parentUUID = referrers[objectUUID] else { throw ProjectInconsistencyError.orphanObject(id: objectUUID, object: object) }
+ let parentGroup = try self.pbxproj.object(id: parentUUID) as PBXGroup
+ guard let groupURL = try resolveURL(objectUUID: parentUUID, object: parentGroup) else { return nil }
+ return object.path.map { groupURL.appendingPathComponent($0) } ?? groupURL
+ case .projectRoot:
+ return object.path.map { URL(fileURLWithPath: $0, relativeTo: projectDirectory) } ?? projectDirectory
+ case .buildProductsDir, .devDir, .sdkDir:
+ print("\(self.projectURL.path): warning: Reference \(objectUUID) is relative to \(object.sourceTree.rawValue), which is not supported by the linter")
+ return nil
+ }
+ }
+}
+
+// MARK: - Implementation Details
+
+/// "Parent" type for all the PBX... types of objects encountered in a pbxproj
+protocol PBXObject: Decodable {
+ static var isa: String { get }
+}
+extension PBXObject {
+ static var isa: String { String(describing: self) }
+}
+
+/// "Parent" type for PBXObjects referencing relative path information (`PBXFileReference`, `PBXGroup`)
+protocol PBXReference: PBXObject {
+ var name: String? { get }
+ var path: String? { get }
+ var sourceTree: Xcodeproj.SourceTree { get }
+}
+
+/// Types used to parse and decode the internals of a `*.xcodeproj/project.pbxproj` file
+extension Xcodeproj {
+ /// An error `thrown` when an inconsistency is found while parsing the `.pbxproj` file.
+ enum ProjectInconsistencyError: Swift.Error, CustomStringConvertible {
+ case objectNotFound(id: ObjectUUID)
+ case unexpectedObjectType(id: ObjectUUID, expectedType: Any.Type, found: PBXObject)
+ case incorrectAbsolutePath(id: ObjectUUID)
+ case orphanObject(id: ObjectUUID, object: PBXObject)
+
+ var description: String {
+ switch self {
+ case .objectNotFound(id: let id):
+ return "Unable to find object with UUID `\(id)`"
+ case .unexpectedObjectType(id: let id, expectedType: let expectedType, found: let found):
+ return "Object with UUID `\(id)` was expected to be of type \(expectedType) but found \(found) instead"
+ case .incorrectAbsolutePath(id: let id):
+ return "Object `\(id)` has `sourceTree = \(Xcodeproj.SourceTree.absolute)` but no `path`"
+ case .orphanObject(id: let id, object: let object):
+ return "Unable to find parent group of \(object) (`\(id)`) during file path resolution"
+ }
+ }
+ }
+
+ /// Type used to represent and decode the root object of a `.pbxproj` file.
+ struct PBXProjFile: Decodable {
+ let rootObject: ObjectUUID
+ let objects: [String: PBXObjectWrapper]
+
+ // Convenience methods
+
+ /// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project.
+ func object(id: ObjectUUID) throws -> T {
+ guard let wrapped = objects[id] else { throw ProjectInconsistencyError.objectNotFound(id: id) }
+ guard let obj = wrapped.wrappedValue as? T else {
+ throw ProjectInconsistencyError.unexpectedObjectType(id: id, expectedType: T.self, found: wrapped.wrappedValue)
+ }
+ return obj
+ }
+
+ /// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project.
+ func object(id: ObjectUUID) -> T? {
+ try? object(id: id) as T
+ }
+
+ /// The `PBXProject` corresponding to the `rootObject` of the project file.
+ var rootProject: PBXProject { try! object(id: rootObject) }
+
+ /// The `PBXGroup` corresponding to the main groop serving as root for the whole hierarchy of files and groups in the project.
+ var mainGroup: PBXGroup { try! object(id: rootProject.mainGroup) }
+
+ /// The list of `PBXNativeTarget` targets found in the project.
+ var nativeTargets: [PBXNativeTarget] { rootProject.targets.compactMap(object(id:)) }
+
+ /// The list of `PBXBuildFile` build file references included in a given target.
+ func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] {
+ guard let sourceBuildPhase: PBXSourcesBuildPhase = target.buildPhases.lazy.compactMap(object(id:)).first else { return [] }
+ return sourceBuildPhase.files.compactMap(object(id:)) as [PBXBuildFile]
+ }
+ }
+
+ /// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
+ /// Represents the root project object.
+ struct PBXProject: PBXObject {
+ let mainGroup: ObjectUUID
+ let targets: [ObjectUUID]
+ }
+
+ /// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
+ /// Represents a native target (i.e. a target building an app, app extension, bundle...).
+ /// - note: Does not represent other types of targets like `PBXAggregateTarget`, only native ones.
+ struct PBXNativeTarget: PBXObject {
+ let name: String
+ let buildPhases: [ObjectUUID]
+ let productType: String
+ var knownProductType: ProductType? { ProductType(rawValue: productType) }
+
+ enum ProductType: String, Decodable {
+ case app = "com.apple.product-type.application"
+ case appExtension = "com.apple.product-type.app-extension"
+ case unitTest = "com.apple.product-type.bundle.unit-test"
+ case uiTest = "com.apple.product-type.bundle.ui-testing"
+ case framework = "com.apple.product-type.framework"
+ }
+ }
+
+ /// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
+ /// Represents a "Compile Sources" build phase containing a list of files to compile.
+ /// - note: Does not represent other types of Build Phases that could exist in the project, only "Compile Sources" one
+ struct PBXSourcesBuildPhase: PBXObject {
+ let files: [ObjectUUID]
+ }
+
+ /// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
+ /// Represents a single build file in a `PBXSourcesBuildPhase` build phase.
+ struct PBXBuildFile: PBXObject {
+ let fileRef: ObjectUUID?
+ }
+
+ /// This type is used to indicate what a file reference in the project is actually relative to
+ enum SourceTree: String, Decodable, CustomStringConvertible {
+ case absolute = ""
+ case group = ""
+ case projectRoot = "SOURCE_ROOT"
+ case buildProductsDir = "BUILT_PRODUCTS_DIR"
+ case devDir = "DEVELOPER_DIR"
+ case sdkDir = "SDKROOT"
+ var description: String { rawValue }
+ }
+
+ /// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
+ /// Represents a reference to a file contained in the project tree.
+ struct PBXFileReference: PBXReference {
+ let name: String?
+ let path: String?
+ let sourceTree: SourceTree
+ }
+
+ /// One of the many `PBXObject` types encountered in the `.pbxproj` file format.
+ /// Represents a group (aka "folder") contained in the project tree.
+ struct PBXGroup: PBXReference {
+ let name: String?
+ let path: String?
+ let sourceTree: SourceTree
+ let children: [ObjectUUID]
+ }
+
+ /// Fallback type for any unknown `PBXObject` type.
+ struct UnknownPBXObject: PBXObject {
+ let isa: String
+ }
+
+ /// Wrapper helper to decode any `PBXObject` based on the value of their `isa` field
+ @propertyWrapper
+ struct PBXObjectWrapper: Decodable, CustomDebugStringConvertible {
+ let wrappedValue: PBXObject
+
+ static let knownTypes: [PBXObject.Type] = [
+ PBXProject.self,
+ PBXGroup.self,
+ PBXFileReference.self,
+ PBXNativeTarget.self,
+ PBXSourcesBuildPhase.self,
+ PBXBuildFile.self
+ ]
+
+ init(from decoder: Decoder) throws {
+ let untypedObject = try UnknownPBXObject(from: decoder)
+ if let objectType = Self.knownTypes.first(where: { $0.isa == untypedObject.isa }) {
+ self.wrappedValue = try objectType.init(from: decoder)
+ } else {
+ self.wrappedValue = untypedObject
+ }
+ }
+ var debugDescription: String { String(describing: wrappedValue) }
+ }
+}
+
+
+
+// MARK: - Lint method
+
+/// The outcome of running our lint logic on a file
+enum LintResult { case ok, skipped, violationsFound([(line: Int, col: Int)]) }
+
+/// Lint a given file for usages of `NSLocalizedString` instead of `AppLocalizedString`
+func lint(fileAt url: URL, targetName: String) throws -> LintResult {
+ guard ["m", "swift"].contains(url.pathExtension) else { return .skipped }
+ let content = try String(contentsOf: url)
+ var lineNo = 0
+ var violations: [(line: Int, col: Int)] = []
+ content.enumerateLines { line, _ in
+ lineNo += 1
+ guard line.range(of: "\\s*//", options: .regularExpression) == nil else { return } // Skip commented lines
+ guard let range = line.range(of: "NSLocalizedString") else { return }
+
+ // Violation found, report it
+ let colNo = line.distance(from: line.startIndex, to: range.lowerBound)
+ let message = "Use `AppLocalizedString` instead of `NSLocalizedString` in source files that are used in the `\(targetName)` extension target. See paNNhX-nP-p2 for more info."
+ print("\(url.path):\(lineNo):\(colNo): error: \(message)")
+ violations.append((lineNo, colNo))
+ }
+ return violations.isEmpty ? .ok : .violationsFound(violations)
+}
+
+
+
+// MARK: - Main (Script Code entry point)
+
+// 1st arg = project path
+let args = CommandLine.arguments.dropFirst()
+guard let projectPath = args.first, !projectPath.isEmpty else { print("You must provide the path to the xcodeproj as first argument."); exit(1) }
+do {
+ let project = try Xcodeproj(path: projectPath)
+
+ // 2nd arg (optional) = name of target to lint
+ let targetsToLint: [Xcodeproj.PBXNativeTarget]
+ if let targetName = args.dropFirst().first, !targetName.isEmpty {
+ print("Selected target: \(targetName)")
+ targetsToLint = project.nativeTargets.filter { $0.name == targetName }
+ } else {
+ print("Linting all app extension targets")
+ targetsToLint = project.nativeTargets.filter { $0.knownProductType == .appExtension }
+ }
+
+ // Lint each requested target
+ var violationsFound = 0
+ for target in targetsToLint {
+ let buildFiles: [Xcodeproj.PBXBuildFile] = project.buildFiles(for: target)
+ print("Linting the Build Files for \(target.name):")
+ for buildFile in buildFiles {
+ guard let fileURL = try project.resolveURL(to: buildFile) else { continue }
+ let result = try lint(fileAt: fileURL.absoluteURL, targetName: target.name)
+ print(" - \(fileURL.relativePath) [\(result)]")
+ if case .violationsFound(let list) = result { violationsFound += list.count }
+ }
+ }
+ print("Done! \(violationsFound) violation(s) found.")
+ exit(violationsFound > 0 ? 1 : 0)
+} catch let error {
+ print("\(projectPath): error: Error while parsing the project file \(projectPath): \(error.localizedDescription)")
+ exit(2)
+}
diff --git a/Scripts/allowSimulatorPhotosAccess.sh b/Scripts/allowSimulatorPhotosAccess.sh
deleted file mode 100755
index a836786f9b51..000000000000
--- a/Scripts/allowSimulatorPhotosAccess.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/perl
-$currentUserID = `id -un`;
-chomp($currentUserID);
-$folderLocations = `find "/Users/$currentUserID/Library/Developer/CoreSimulator/Devices" -name TCC`;
-print "currentUserID: $currentUserID\n\n";
-
-while($folderLocations =~ /(..*)/g) {
- print "folder: $1\n";
- `sqlite3 "$1/TCC.db" "insert into access values('kTCCServicePhotos','org.wordpress', 0, 1, 1, null, null)"`;
- print "\n";
-}
diff --git a/Scripts/coverage.py b/Scripts/coverage.py
deleted file mode 100644
index db6b8cc22698..000000000000
--- a/Scripts/coverage.py
+++ /dev/null
@@ -1,326 +0,0 @@
-import glob
-import os
-import shutil
-import subprocess
-import StringIO
-import sys
-
-# Directories
-dataDirectory = "./CoverageData"
-cacheDirectory = dataDirectory + "/Cache"
-derivedDataDirectory = dataDirectory + "/DerivedData"
-buildObjectsDirectory = derivedDataDirectory + "/Build/Intermediates/WordPress.build/Debug-iphonesimulator/WordPress.build/Objects-normal/x86_64"
-gcovOutputDirectory = dataDirectory + "/GCOVOutput"
-finalReport = dataDirectory + "/FinalReport"
-
-# Files
-gcovOutputFileName = gcovOutputDirectory + "/gcov.output"
-
-# File Patterns
-allGcdaFiles = "/*.gcda"
-allGcnoFiles = "/*.gcno"
-
-# Data conversion methods
-
-def IsInt(i):
- try:
- int(i)
- return True
- except ValueError:
- return False
-
-# Directory methods
-
-def copyFiles(sourcePattern, destination):
- assert sourcePattern
-
- for file in glob.glob(sourcePattern):
- shutil.copy(file, destination)
-
- return
-
-def createDirectoryIfNecessary(directory):
- if not os.path.exists(directory):
- os.makedirs(directory)
- return
-
-def removeDirectory(directory):
- assert directory
- assert directory.startswith(dataDirectory)
- subprocess.call(["rm",
- "-rf",
- directory])
- return
-
-def removeFileIfNecessary(file):
- if os.path.isfile(gcovOutputFileName):
- os.remove(gcovOutputFileName)
- return
-
-# Xcode interaction methods
-
-def xcodeBuildOperation(operation, simulator):
- assert operation
-
- return subprocess.call(["xcodebuild",
- operation,
- "-workspace",
- "../WordPress.xcworkspace",
- "-scheme",
- "WordPress",
- "-configuration",
- "Debug",
- "-destination",
- "platform=" + simulator,
- "-derivedDataPath",
- derivedDataDirectory,
- "GCC_GENERATE_TEST_COVERAGE_FILES=YES",
- "GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES"])
-
-def xcodeClean(simulator):
- return xcodeBuildOperation("clean", simulator)
-
-def xcodeBuild(simulator):
- return xcodeBuildOperation("build", simulator)
-
-def xcodeTest(simulator):
- return xcodeBuildOperation("test", simulator)
-
-# Simulator interaction methods
-
-def simulatorEraseContentAndSettings(simulator):
-
- deviceID = simulator[2]
-
- command = ["xcrun",
- "simctl",
- "erase",
- deviceID]
- result = subprocess.call(command)
-
- if (result != 0):
- exit("Error: subprocess xcrun failed to erase content and settings for device ID: " + deviceID + ".")
-
- return
-
-# Caching methods
-
-def cacheAllGcdaFiles():
- allGcdaFilesPath = buildObjectsDirectory + allGcdaFiles
- copyFiles(allGcdaFilesPath, cacheDirectory)
- return
-
-def cacheAllGcnoFiles():
- allGcnoFilesPath = buildObjectsDirectory + allGcnoFiles
- copyFiles(allGcnoFilesPath, cacheDirectory)
- return
-
-# Core procedures
-
-def createInitialDirectories():
- createDirectoryIfNecessary(dataDirectory)
- createDirectoryIfNecessary(cacheDirectory)
- createDirectoryIfNecessary(derivedDataDirectory)
- createDirectoryIfNecessary(gcovOutputDirectory)
- createDirectoryIfNecessary(finalReport)
- return
-
-def generateGcdaAndGcnoFiles(simulator):
- if xcodeClean(simulator) != 0:
- sys.exit("Exit: the clean procedure failed.")
-
- if xcodeBuild(simulator) != 0:
- sys.exit("Exit: the build procedure failed.")
-
- if xcodeTest(simulator) != 0:
- sys.exit("Exit: the test procedure failed.")
-
- cacheAllGcdaFiles()
- cacheAllGcnoFiles()
- return
-
-def processGcdaAndGcnoFiles():
-
- removeFileIfNecessary(gcovOutputFileName)
- gcovOutputFile = open(gcovOutputFileName, "wb")
-
- sourceFilesPattern = cacheDirectory + allGcnoFiles
-
- for file in glob.glob(sourceFilesPattern):
- fileWithPath = "../../" + file
-
- command = ["gcov", fileWithPath]
-
- subprocess.call(command,
- cwd = gcovOutputDirectory,
- stdout = gcovOutputFile)
- return
-
-# Selecting a Simulator
-
-def availableSimulators():
- command = ["xcrun",
- "simctl",
- "list",
- "devices"]
-
- process = subprocess.Popen(command,
- stdout = subprocess.PIPE)
- out, err = process.communicate()
-
- simulators = availableSimulatorsFromXcrunOutput(out)
-
- return simulators
-
-def availableSimulatorsFromXcrunOutput(output):
- outStringIO = StringIO.StringIO(output)
-
- iOSVersion = ""
- simulators = []
-
- line = outStringIO.readline()
- line = line.strip("\r").strip("\n")
-
- assert line == "== Devices =="
-
- while True:
- line = outStringIO.readline()
- line = line.strip("\r").strip("\n")
-
- if line.startswith("-- "):
- iOSVersion = line.strip("-- iOS ").strip(" --")
- elif line:
- name = line[4:line.rfind(" (", 0, line.rfind(" ("))]
- id = line[line.rfind("(", 0, line.rfind("(")) + 1:line.rfind(")", 0, line.rfind(")"))]
- simulators.append([iOSVersion, name, id])
- else:
- break
-
- return simulators
-
-def askUserToSelectSimulator(simulators):
- option = ""
-
- while True:
- print "\r\nPlease select a simulator:\r\n"
-
- for idx, simulator in enumerate(simulators):
- print str(idx) + " - iOS Version: " + simulator[0] + " - Name: " + simulator[1] + " - ID: " + simulator[2]
- print "x - Exit\r\n"
-
- option = raw_input(": ")
-
- if option == "x":
- exit(0)
- elif IsInt(option):
- intOption = int(option)
- if intOption >= 0 and intOption < len(simulators):
- break
-
- print "Invalid option!"
- return int(option)
-
-def selectSimulator():
- result = None
- simulators = availableSimulators()
-
- if (len(simulators) > 0):
- option = askUserToSelectSimulator(simulators)
-
- assert option >= 0 and option < len(simulators)
-
- simulatorEraseContentAndSettings(simulators[option])
-
- result = "iOS Simulator,name=" + simulators[option][1] + ",OS=" + simulators[option][0]
- print "Selected simulator: " + result
-
- return result
-
-# Parsing the data
-
-def parseCoverageData(line):
- header = "Lines executed:"
-
- assert line.startswith(header)
-
- line = line[len(header):]
- lineComponents = line.split(" of ")
-
- percentage = float(lineComponents[0].strip("%")) / 100
- totalLines = int(lineComponents[1])
- linesExecuted = int(round(percentage * totalLines))
-
- return str(percentage), str(totalLines), str(linesExecuted)
-
-def parseFilePath(line):
- assert line.startswith("File '")
-
- splitStrings = line.split("'")
- path = splitStrings[1]
-
- parentDir = os.path.dirname(os.getcwd())
-
- if path.startswith(parentDir):
- path = path[len(parentDir):]
- else:
- path = None
-
- return path
-
-def parseGcovFiles():
- gcovFile = open(gcovOutputFileName, "r")
- csvFile = open(finalReport + "/report.csv", "w")
-
- lineNumber = 0
- skipNext = False
-
- csvFile.write("File, Covered Lines, Total Lines, Coverage Percentage\r\n")
-
- for line in gcovFile:
- lineOffset = lineNumber % 4
-
- if lineOffset == 0:
- filePath = parseFilePath(line)
-
- if filePath:
- csvFile.write(filePath + ",")
- else:
- skipNext = True
-
- elif lineOffset == 1:
- if not skipNext:
- percentage, totalLines, linesExecuted = parseCoverageData(line)
-
- csvFile.write(linesExecuted + "," + totalLines + "," + percentage + "\r\n")
- else:
- skipNext = False
-
- lineNumber += 1
-
- return
-
-# Main
-
-def main(arguments):
- createInitialDirectories()
-
- simulator = selectSimulator()
-
- generateGcdaAndGcnoFiles(simulator)
- processGcdaAndGcnoFiles()
- parseGcovFiles()
-
- removeDirectory(derivedDataDirectory)
- return
-
-main(sys.argv)
-print("Done.")
-
-
-
-
-
-
-
-
-
diff --git a/Scripts/exportipa/exportOptions.plist b/Scripts/exportipa/exportOptions.plist
deleted file mode 100644
index 4284b196eaa3..000000000000
--- a/Scripts/exportipa/exportOptions.plist
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
- iCloudContainerEnvironment
- Production
- method
- app-store
- provisioningProfiles
-
- org.wordpress
- WordPress App Store
- org.wordpress.WordPressShare
- WordPress Share App Store Distribution
- org.wordpress.WordPressTodayWidget
- WordPress Today Widget App Store Distribution
-
- signingCertificate
- iPhone Distribution
- signingStyle
- manual
- stripSwiftSymbols
-
- teamID
- PZYM8XX95Q
- uploadBitcode
-
- uploadSymbols
-
-
-
diff --git a/Scripts/exportipa/exportipa.sh b/Scripts/exportipa/exportipa.sh
deleted file mode 100755
index 5dab65d561f3..000000000000
--- a/Scripts/exportipa/exportipa.sh
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/bin/bash
-
-executable=$(basename "$0" ".sh")
-
-if [ $# -ne 3 ] || [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then
- echo "*** Error!"
- echo "usage: $executable workspacePath scheme exportPath"
- echo "example: $executable WordPress.xcworkspace WordPress ~/Desktop/WordPress/ Debug Release"
- echo ""
- exit -1
-fi
-
-workspacePath="$1"
-scheme="$2"
-exportPath="$3"
-
-if [ ! -e "$workspacePath" ]; then
- echo "The specified workspace file does not exist."
- exit -1
-fi
-
-if [ -e exportPath ]; then
- echo "The specified exportPath matches an existing file."
- exit -1
-fi
-
-tmpDir="$(mktemp -d)"
-archiveFile="$tmpDir/archive.xcarchive"
-dsymDir="$tmpDir/archive.xcarchive/dSYMs"
-logFile="$tmpDir/exportipa.log"
-
-function finish {
- echo "Log location: $logFile"
-}
-
-trap finish EXIT
-
-echo "*** Configuration:"
-echo "archiveFile = $archiveFile"
-echo "dsymDir = $dsymDir"
-echo "exportPath = $exportPath"
-echo "scheme = $scheme"
-echo "tmpDir = $tmpDir"
-echo "workspacePath = $workspacePath"
-echo ""
-echo "*** Cleaning for testing."
-xcodebuild -workspace "$workspacePath" -scheme "$scheme" clean -destination "platform=iOS Simulator,name=iPhone 8" >> "$logFile" 2>&1 || exit 1
-echo "*** Testing."
-xcodebuild -workspace "$workspacePath" -scheme "$scheme" test -destination "platform=iOS Simulator,name=iPhone 8" >> "$logFile" 2>&1 || exit 1
-echo "*** Cleaning for building."
-# The clean command for release builds seems to require the configuration to be set.
-xcodebuild -workspace "$workspacePath" -scheme "$scheme" clean -configuration 'Release' >> "$logFile" 2>&1 || exit 1
-echo "*** Building."
-xcodebuild -workspace "$workspacePath" -scheme "$scheme" archive -archivePath "$archiveFile" >> "$logFile" 2>&1 || exit 1
-echo "*** Exporting IPA."
-xcodebuild -exportArchive -archivePath "$archiveFile" -exportOptionsPlist exportOptions.plist -exportPath "$exportPath" >> "$logFile" 2>&1 || exit 1
-echo "*** Archiving and exporting DSYM."
-ditto -c -k --sequesterRsrc --keepParent "$dsymDir" "$exportPath/dSYMs.zip"
-echo ""
-echo "*** Completed!!"
-echo "IPA location: $exportPath"
diff --git a/Scripts/extract-framework-translations.swift b/Scripts/extract-framework-translations.swift
deleted file mode 100755
index 79781ea7d6cb..000000000000
--- a/Scripts/extract-framework-translations.swift
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env swift
-
-import Foundation
-
-let fileManager = FileManager.default
-let cwd = fileManager.currentDirectoryPath
-let script = CommandLine.arguments[0]
-
-let base = cwd
-let projectDir = base.appending("/WordPress")
-let resources = projectDir.appending("/Resources")
-let frameworkRoots = [
- "WordPressTodayWidget",
- "WordPressShareExtension"
- ].map({ projectDir.appending("/\($0)") })
-
-guard fileManager.fileExists(atPath: projectDir) else {
- print("Must run script from project root folder")
- exit(1)
-}
-
-
-func projectLanguages() -> [String] {
- return (try? fileManager.contentsOfDirectory(atPath: resources)
- .filter({ $0.hasSuffix(".lproj") })
- .map({ $0.replacingOccurrences(of: ".lproj", with: "") })
- .filter({ $0 != "en" })
- ) ?? []
-}
-
-func readStrings(path: String) -> [String: String] {
- do {
- let sourceData = try Data(contentsOf: URL(fileURLWithPath: path))
- let source = try PropertyListSerialization.propertyList(from: sourceData, options: [], format: nil) as! [String: String]
- return source
- } catch {
- print("Error reading \(path): \(error)")
- return [:]
- }
-}
-
-func sourceStrings(framework: String) -> [String: String] {
- let sourcePath = framework.appending("/Base.lproj/Localizable.strings")
- return readStrings(path: sourcePath)
-}
-
-func readProjectTranslations(for language: String) -> [String: String] {
- let path = resources.appending("/\(language).lproj/Localizable.strings")
- return readStrings(path: path)
-}
-
-func writeTranslations(_ translations: [String: String], language: String, framework: String) {
- let frameworkName = (framework as NSString).lastPathComponent
- let languageDir = framework.appending("/\(language).lproj")
- let stringsPath = languageDir.appending("/Localizable.strings")
- do {
- try fileManager.createDirectory(atPath: languageDir, withIntermediateDirectories: true, attributes: nil)
- let data = try PropertyListSerialization.data(fromPropertyList: translations, format: .binary, options: 0)
- if !fileManager.fileExists(atPath: stringsPath) {
- print("New \(language) translation for \(frameworkName). Please add it to the Xcode project")
- }
- try data.write(to: URL(fileURLWithPath: stringsPath))
- } catch {
- print("Error writing translation to \(stringsPath): \(error)")
- }
-}
-
-for framework in frameworkRoots {
- let name = (framework as NSString).lastPathComponent
- let sources = sourceStrings(framework: framework)
- var languagesAdded = [String]()
- for language in projectLanguages() {
- let projectTranslations = readProjectTranslations(for: language)
- var translations = sources
- for (key, _) in sources {
- translations[key] = projectTranslations[key]
- }
-
- guard !translations.isEmpty else {
- continue
- }
- languagesAdded.append(language)
-
- writeTranslations(translations, language: language, framework: framework)
- }
- if languagesAdded.isEmpty {
- print("No translations extracted to \(name)")
- } else {
- print("Extracted translations to \(name) for: " + languagesAdded.joined(separator: " "))
- }
-}
diff --git a/Scripts/fastlane/.gitignore b/Scripts/fastlane/.gitignore
deleted file mode 100644
index 1e69c202112e..000000000000
--- a/Scripts/fastlane/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-screenshots
-screenshots_orig
-*.itmsp
diff --git a/Scripts/fastlane/Deliverfile b/Scripts/fastlane/Deliverfile
deleted file mode 100644
index faedc3a5c977..000000000000
--- a/Scripts/fastlane/Deliverfile
+++ /dev/null
@@ -1,18 +0,0 @@
-require 'dotenv'
-Dotenv.load('~/.wpios-env.default')
-
-screenshots_path "./screenshots/"
-app_identifier "org.wordpress"
-
-# Make sure to update these keys for a new version
-app_version "14.3"
-
-privacy_url({
- 'default' => 'https://automattic.com/privacy/',
-})
-
-copyright('2020 Automattic Inc.')
-
-skip_binary_upload true
-overwrite_screenshots true
-phased_release true
diff --git a/Scripts/fastlane/Fastfile b/Scripts/fastlane/Fastfile
deleted file mode 100644
index 053632aa66be..000000000000
--- a/Scripts/fastlane/Fastfile
+++ /dev/null
@@ -1,478 +0,0 @@
-default_platform(:ios)
-fastlane_require 'xcodeproj'
-fastlane_require 'dotenv'
-fastlane_require 'open-uri'
-
-USER_ENV_FILE_PATH = File.join(Dir.home, '.wpios-env.default')
-PROJECT_ENV_FILE_PATH = File.expand_path(File.join(Dir.pwd, '../../.configure-files/project.env'))
-
-# Use this instead of getting values from ENV directly
-# It will throw an error if the requested value is missing
-def get_required_env(key)
- unless ENV.key?(key)
- UI.user_error!("Environment variable '#{key}' is not set. Have you setup #{USER_ENV_FILE_PATH} correctly?")
- end
- ENV[key]
-end
-
-before_all do
- # Check that the env files exist
- unless is_ci || File.file?(USER_ENV_FILE_PATH)
- UI.user_error!("~/.wpios-env.default not found: Please copy env/user.env-example to #{USER_ENV_FILE_PATH} and fill in the values")
- end
- unless File.file?(PROJECT_ENV_FILE_PATH)
- UI.user_error!("project.env not found: Make sure your configuration is up to date with `rake dependencies`")
- end
-
- # This allows code signing to work on CircleCI
- # It is skipped if this isn't running on CI
- # See https://circleci.com/docs/2.0/ios-codesigning/
- setup_circle_ci
-end
-
-platform :ios do
-########################################################################
-# Environment
-########################################################################
-Dotenv.load(USER_ENV_FILE_PATH)
-Dotenv.load(PROJECT_ENV_FILE_PATH)
-ENV[GHHELPER_REPO="wordpress-mobile/wordpress-iOS"]
-ENV["PROJECT_NAME"]="WordPress"
-ENV["PUBLIC_CONFIG_FILE"]="../config/Version.Public.xcconfig"
-ENV["INTERNAL_CONFIG_FILE"]="../config/Version.internal.xcconfig"
-ENV["DOWNLOAD_METADATA"]="./fastlane/download_metadata.swift"
-ENV["PROJECT_ROOT_FOLDER"]="../"
-
-########################################################################
-# Screenshots
-########################################################################
-import "./ScreenshotFastfile"
-
-########################################################################
-# Release Lanes
-########################################################################
- #####################################################################################
- # code_freeze
- # -----------------------------------------------------------------------------------
- # This lane executes the steps planned on code freeze
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane code_freeze [skip_confirm:]
- #
- # Example:
- # bundle exec fastlane code_freeze
- # bundle exec fastlane code_freeze skip_confirm:true
- #####################################################################################
- desc "Creates a new release branch from the current develop"
- lane :code_freeze do | options |
- old_version = ios_codefreeze_prechecks(options)
-
- ios_bump_version_release()
- new_version = ios_get_app_version()
- ios_update_release_notes(new_version: new_version)
- setbranchprotection(repository:GHHELPER_REPO, branch: "release/#{new_version}")
- setfrozentag(repository:GHHELPER_REPO, milestone: new_version)
-
- ios_localize_project()
- ios_tag_build()
- get_prs_list(repository:GHHELPER_REPO, start_tag:"#{old_version}", report_path:"#{File.expand_path('~')}/wpios_prs_list_#{old_version}_#{new_version}.txt")
- end
-
- #####################################################################################
- # update_appstore_strings
- # -----------------------------------------------------------------------------------
- # This lane updates the AppStoreStrings.po files with the latest content from
- # the release_notes.txt file and the other text sources
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane update_appstore_strings version:
- #
- # Example:
- # bundle exec fastlane update_appstore_strings version:10.7
- #####################################################################################
- desc "Updates the AppStoreStrings.po file with the latest data"
- lane :update_appstore_strings do | options |
- prj_folder = Pathname.new(File.join(Dir.pwd, "../..")).expand_path.to_s
- source_metadata_folder = File.join(prj_folder, "Scripts/fastlane/appstoreres/metadata/source")
-
- files = {
- whats_new: File.join(prj_folder, "/WordPress/Resources/release_notes.txt"),
- app_store_subtitle: File.join(source_metadata_folder, "subtitle.txt"),
- app_store_desc: File.join(source_metadata_folder, "description.txt"),
- app_store_keywords: File.join(source_metadata_folder, "keywords.txt"),
- "standard-whats-new-1" => File.join(source_metadata_folder, "standard_whats_new_1.txt"),
- "standard-whats-new-2" => File.join(source_metadata_folder, "standard_whats_new_2.txt"),
- "standard-whats-new-3" => File.join(source_metadata_folder, "standard_whats_new_3.txt"),
- "standard-whats-new-4" => File.join(source_metadata_folder, "standard_whats_new_4.txt"),
- "app_store_screenshot-1" => File.join(source_metadata_folder, "promo_screenshot_1.txt"),
- "app_store_screenshot-2" => File.join(source_metadata_folder, "promo_screenshot_2.txt"),
- "app_store_screenshot-3" => File.join(source_metadata_folder, "promo_screenshot_3.txt"),
- "app_store_screenshot-4" => File.join(source_metadata_folder, "promo_screenshot_4.txt"),
- "app_store_screenshot-5" => File.join(source_metadata_folder, "promo_screenshot_5.txt"),
- }
-
- ios_update_metadata_source(po_file_path: prj_folder + "/WordPress/Resources/AppStoreStrings.po",
- source_files: files,
- release_version: options[:version])
- end
-
- #####################################################################################
- # new_beta_release
- # -----------------------------------------------------------------------------------
- # This lane updates the release branch for a new beta release. It will update the
- # current release branch by default. If you want to update a different branch
- # (i.e. hotfix branch) pass the related version with the 'base_version' param
- # (example: base_version:10.6.1 will work on the 10.6.1 branch)
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane new_beta_release [skip_confirm:] [base_version:]
- #
- # Example:
- # bundle exec fastlane new_beta_release
- # bundle exec fastlane new_beta_release skip_confirm:true
- # bundle exec fastlane new_beta_release base_version:10.6.1
- #####################################################################################
- desc "Updates a release branch for a new beta release"
- lane :new_beta_release do | options |
- ios_betabuild_prechecks(options)
- ios_bump_version_beta()
- ios_tag_build()
- end
-
- #####################################################################################
- # new_hotfix_release
- # -----------------------------------------------------------------------------------
- # This lane updates the release branch for a new hotix release.
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane new_hotfix_release [skip_confirm:] [version:]
- #
- # Example:
- # bundle exec fastlane new_hotfix_release version:10.6.1
- # bundle exec fastlane new_hotfix_release skip_confirm:true version:10.6.1
- #####################################################################################
- desc "Creates a new hotfix branch from the given tag"
- lane :new_hotfix_release do | options |
- prev_ver = ios_hotfix_prechecks(options)
- ios_bump_version_hotfix(previous_version: prev_ver, version: options[:version])
- ios_tag_build()
- end
-
- #####################################################################################
- # finalize_release
- # -----------------------------------------------------------------------------------
- # This lane finalize a release: updates store metadata, pushes the final tag and
- # cleans all the temp ones
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane finalize_release [skip_confirm:] [version:]
- #
- # Example:
- # bundle exec fastlane finalize_release
- # bundle exec fastlane finalize_release skip_confirm:true
- #####################################################################################
- desc "Removes all the temp tags and puts the final one"
- lane :finalize_release do | options |
- ios_finalize_prechecks(options)
- ios_update_metadata(options) unless ios_current_branch_is_hotfix
- ios_bump_version_beta() unless ios_current_branch_is_hotfix
- ios_final_tag(options)
-
- # Wrap up
- version = ios_get_app_version()
- removebranchprotection(repository:GHHELPER_REPO, branch: "release/#{version}")
- setfrozentag(repository:GHHELPER_REPO, milestone: version, freeze: false)
- create_new_milestone(repository:GHHELPER_REPO)
- close_milestone(repository:GHHELPER_REPO, milestone: version)
- end
-
- #####################################################################################
- # build_and_upload_release
- # -----------------------------------------------------------------------------------
- # This lane builds the app and upload it for both internal and external distribution
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane build_and_upload_release [skip_confirm:]
- #
- # Example:
- # bundle exec fastlane build_and_upload_release
- # bundle exec fastlane build_and_upload_release skip_confirm:true
- #####################################################################################
- desc "Builds and updates for distribution"
- lane :build_and_upload_release do | options |
- ios_build_prechecks(skip_confirm: options[:skip_confirm],
- internal: true,
- external: true)
- ios_build_preflight()
- build_and_upload_internal(skip_prechecks: true, skip_confirm: options[:skip_confirm])
- build_and_upload_itc(skip_prechecks: true, skip_confirm: options[:skip_confirm])
- end
-
- #####################################################################################
- # build_and_upload_installable_build
- # -----------------------------------------------------------------------------------
- # This lane builds the app and upload it for adhoc testing
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane build_and_upload_installable_build [version_long:]
- #
- # Example:
- # bundle exec fastlane build_and_upload_installable_build
- # bundle exec fastlane build_and_upload_installable_build build_number:123
- #####################################################################################
- desc "Builds and uploads an installable build"
- lane :build_and_upload_installable_build do | options |
- alpha_code_signing
-
- # Get the current build version, and update it if needed
- version_config_path = "../../config/Version.internal.xcconfig"
- versions = Xcodeproj::Config.new(File.new(version_config_path)).to_hash
- build_number = versions["VERSION_LONG"]
-
- if options.key?(:build_number)
- build_number = options[:build_number]
-
- UI.message("Updating build version to #{build_number}")
-
- versions["VERSION_LONG"] = build_number
- new_config = Xcodeproj::Config.new(versions)
- new_config.save_as(Pathname.new(version_config_path))
- end
-
- gym(
- scheme: "WordPress Alpha",
- workspace: "../WordPress.xcworkspace",
- export_method: "enterprise",
- clean: true,
- output_directory: "../build/",
- derived_data_path: "../derived-data/alpha/",
- export_team_id: ENV["INT_EXPORT_TEAM_ID"],
- export_options: { method: "enterprise" })
-
- sh("mv ../../build/WordPress.ipa \"../../build/WordPress Alpha.ipa\"")
-
- appcenter_upload(
- api_token: get_required_env("APPCENTER_API_TOKEN"),
- owner_name: "automattic",
- owner_type: "organization",
- app_name: "WPiOS-One-Offs",
- file: "../build/WordPress Alpha.ipa",
- destinations: "All-users-of-WPiOS-One-Offs",
- notify_testers: false
- )
-
- download_url = Actions.lane_context[SharedValues::APPCENTER_DOWNLOAD_LINK]
- UI.message("Successfully built and uploaded installable build here: #{download_url}")
- install_url = "https://install.appcenter.ms/orgs/automattic/apps/WPiOS-One-Offs/"
-
- # Create a comment.json file so that Peril to comment with the build details, if this is running on CI
- comment_body = "You can test the changes on this Pull Request by downloading it from AppCenter [here](#{install_url}) with build number: #{build_number}. IPA is available [here](#{download_url}). If you need access to this, you can ask a maintainer to add you."
- File.write("comment.json", { body: comment_body }.to_json)
- end
-
- #####################################################################################
- # build_and_upload_internal
- # -----------------------------------------------------------------------------------
- # This lane builds the app and upload it for internal testing
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane build_and_upload_internal [skip_confirm:]
- #
- # Example:
- # bundle exec fastlane build_and_upload_internal
- # bundle exec fastlane build_and_upload_internal skip_confirm:true
- #####################################################################################
- desc "Builds and uploads for distribution"
- lane :build_and_upload_internal do | options |
- ios_build_prechecks(skip_confirm: options[:skip_confirm], internal: true) unless (options[:skip_prechecks])
- ios_build_preflight() unless (options[:skip_prechecks])
-
- internal_code_signing
-
- gym(
- scheme: "WordPress Internal",
- workspace: "../WordPress.xcworkspace",
- export_method: "enterprise",
- clean: true,
- output_directory: "../build/",
- derived_data_path: "../derived-data/internal/",
- export_team_id: get_required_env("INT_EXPORT_TEAM_ID"),
- export_options: { method: "enterprise" })
-
- sh("mv ../../build/WordPress.ipa \"../../build/WordPress Internal.ipa\"")
-
- appcenter_upload(
- api_token: ENV["APPCENTER_API_TOKEN"],
- owner_name: "automattic",
- owner_type: "organization",
- app_name: "WP-Internal",
- file: "../build/WordPress Internal.ipa",
- notify_testers: false
- )
-
-
- dSYM_PATH = File.dirname(File.dirname(Dir.pwd)) + "/build/WordPress.app.dSYM.zip"
-
- sentry_upload_dsym(
- auth_token: get_required_env("SENTRY_AUTH_TOKEN"),
- org_slug: 'a8c',
- project_slug: 'wordpress-ios',
- dsym_path: dSYM_PATH,
- )
-
- end
-
- #####################################################################################
- # build_and_upload_itc
- # -----------------------------------------------------------------------------------
- # This lane builds the app and upload it for external distribution
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane build_and_upload_itc [skip_confirm:] [create_release: ]
- #
- # Example:
- # bundle exec fastlane build_and_upload_itc
- # bundle exec fastlane build_and_upload_itc skip_confirm:true
- # bundle exec fastlane build_and_upload_itc create_release:true
- #####################################################################################
- desc "Builds and uploads for distribution"
- lane :build_and_upload_itc do | options |
- ios_build_prechecks(skip_confirm: options[:skip_confirm], external: true) unless (options[:skip_prechecks])
- ios_build_preflight() unless (options[:skip_prechecks])
-
- appstore_code_signing
-
- gym(scheme: "WordPress", workspace: "../WordPress.xcworkspace",
- clean: true,
- export_team_id: get_required_env("EXT_EXPORT_TEAM_ID"),
- derived_data_path: "../derived-data/itc/",
- export_options: { method: "app-store" }
- )
-
- testflight(
- skip_waiting_for_build_processing: true,
- team_id: "299112",
- )
-
- sh("cd .. && rm WordPress.ipa")
- dSYM_PATH = File.dirname(Dir.pwd) + "/WordPress.app.dSYM.zip"
-
- sentry_upload_dsym(
- dsym_path: dSYM_PATH,
- auth_token: get_required_env("SENTRY_AUTH_TOKEN"),
- org_slug: 'a8c',
- project_slug: 'wordpress-ios',
- )
-
- sh("cd .. && rm WordPress.app.dSYM.zip")
-
- if (options[:create_release])
- archive_zip_path = File.dirname(Dir.pwd) + "/WordPress.xarchive.zip"
- zip(path: lane_context[SharedValues::XCODEBUILD_ARCHIVE], output_path: archive_zip_path)
-
- version = ios_get_app_version()
- create_release(repository:GHHELPER_REPO,
- version: version,
- release_notes_file_path:'../WordPress/Resources/release_notes.txt',
- release_assets:"#{archive_zip_path}"
- )
-
- sh("rm #{archive_zip_path}")
- end
- end
-
- #####################################################################################
- # build_release
- # -----------------------------------------------------------------------------------
- # This lane builds the app, create a GH release and upload it for external distribution
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane build_release [skip_confirm:]
- #
- # Example:
- # bundle exec fastlane build_release
- # bundle exec fastlane build_release skip_confirm:true
- #####################################################################################
- desc "Builds, create release and uploads for distribution"
- lane :build_release do | options |
- build_and_upload_itc(skip_confirm: options[:skip_confirm], create_release: true)
- end
-
-########################################################################
-# Cnfigure Lanes
-########################################################################
- #####################################################################################
- # update_certs_and_profiles
- # -----------------------------------------------------------------------------------
- # This lane downloads all the required certs and profiles and,
- # if not run on CI it creates the missing ones.
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane update_certs_and_profiles
- #
- # Example:
- # bundle exec fastlane update_certs_and_profiles
- #####################################################################################
- lane :update_certs_and_profiles do | options |
- alpha_code_signing
- internal_code_signing
- appstore_code_signing
- end
-
- ########################################################################
- # Fastlane match code signing
- ########################################################################
- private_lane :alpha_code_signing do |options|
- match(
- type: "enterprise",
- team_id: get_required_env("INT_EXPORT_TEAM_ID"),
- readonly: options[:readonly] || is_ci,
- app_identifier: ["org.wordpress.alpha",
- "org.wordpress.alpha.WordPressShare",
- "org.wordpress.alpha.WordPressDraftAction",
- "org.wordpress.alpha.WordPressTodayWidget",
- "org.wordpress.alpha.WordPressNotificationServiceExtension",
- "org.wordpress.alpha.WordPressNotificationContentExtension",
- "org.wordpress.alpha.WordPressAllTimeWidget",
- "org.wordpress.alpha.WordPressThisWeekWidget"])
- end
-
- private_lane :internal_code_signing do |options|
- match(
- type: "enterprise",
- team_id: get_required_env("INT_EXPORT_TEAM_ID"),
- readonly: options[:readonly] || is_ci,
- app_identifier: ["org.wordpress.internal",
- "org.wordpress.internal.WordPressShare",
- "org.wordpress.internal.WordPressDraftAction",
- "org.wordpress.internal.WordPressTodayWidget",
- "org.wordpress.internal.WordPressNotificationServiceExtension",
- "org.wordpress.internal.WordPressNotificationContentExtension",
- "org.wordpress.internal.WordPressAllTimeWidget",
- "org.wordpress.internal.WordPressThisWeekWidget"])
- end
-
- private_lane :appstore_code_signing do |options|
- match(
- type: "appstore",
- team_id: get_required_env("EXT_EXPORT_TEAM_ID"),
- readonly: options[:readonly] || is_ci,
- app_identifier: ["org.wordpress",
- "org.wordpress.WordPressShare",
- "org.wordpress.WordPressDraftAction",
- "org.wordpress.WordPressTodayWidget",
- "org.wordpress.WordPressNotificationServiceExtension",
- "org.wordpress.WordPressNotificationContentExtension",
- "org.wordpress.WordPressAllTimeWidget",
- "org.wordpress.WordPressThisWeekWidget"])
- end
-
-########################################################################
-# Helper Lanes
-########################################################################
- desc "Get a list of pull request from `start_tag` to the current state"
- lane :get_pullrequests_list do | options |
- get_prs_list(repository:GHHELPER_REPO, start_tag:"#{options[:start_tag]}", report_path:"#{File.expand_path('~')}/wpios_prs_list.txt")
- end
-
-end
diff --git a/Scripts/fastlane/Matchfile b/Scripts/fastlane/Matchfile
deleted file mode 100644
index 038edec0f4e6..000000000000
--- a/Scripts/fastlane/Matchfile
+++ /dev/null
@@ -1,6 +0,0 @@
-# This Matchfile has the shared properties used for all signing types
-
-# Store certs/profiles encrypted in Google Cloud
-storage_mode("google_cloud")
-google_cloud_bucket_name("a8c-fastlane-match")
-google_cloud_keys_file("../.configure-files/google_cloud_keys.json")
diff --git a/Scripts/fastlane/Pluginfile b/Scripts/fastlane/Pluginfile
deleted file mode 100644
index a42e93be1c00..000000000000
--- a/Scripts/fastlane/Pluginfile
+++ /dev/null
@@ -1,11 +0,0 @@
-# Autogenerated by fastlane
-#
-# Ensure this file is checked in to source control!
-
-group :screenshots, optional: true do
- gem 'rmagick', '~> 3.2.0'
-end
-
-gem 'fastlane-plugin-wpmreleasetoolkit', git: 'https://github.com/wordpress-mobile/release-toolkit', tag: '0.9.0'
-gem 'fastlane-plugin-sentry'
-gem 'fastlane-plugin-appcenter', '1.7.1'
diff --git a/Scripts/fastlane/ScreenshotFastfile b/Scripts/fastlane/ScreenshotFastfile
deleted file mode 100644
index f065aa335cda..000000000000
--- a/Scripts/fastlane/ScreenshotFastfile
+++ /dev/null
@@ -1,154 +0,0 @@
-require 'fileutils'
-
-default_platform(:ios)
-
-platform :ios do
-########################################################################
-# Screenshot Lanes
-########################################################################
- #####################################################################################
- # screenshots
- # -----------------------------------------------------------------------------------
- # This lane generates the localised screenshots.
- # It is the same as running bundle exec fastlane snapshot, but ensures that the app
- # is only built once.
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane screenshots
- #
- # Example:
- # bundle exec fastlane screenshots
- #####################################################################################
- desc "Generate localised screenshots"
- lane :screenshots do |options|
- fastlane_directory = File.expand_path File.dirname(__FILE__)
- derived_data_path = File.join(fastlane_directory, "DerivedData")
-
- Dir.chdir("../../") do
- FileUtils.rm_rf('Pods')
- end
-
- sh('bundle exec pod install')
- FileUtils.rm_rf(derived_data_path)
-
- scan(
- workspace: File.join(fastlane_directory, "../../WordPress.xcworkspace"),
- scheme: "WordPressScreenshotGeneration",
- build_for_testing: true,
- derived_data_path: derived_data_path,
- )
-
- # By default, clear previous screenshots
- should_clear_previous_screenshots = true
- languages = "da de-DE en-AU en-CA en-GB en-US es-ES fr-FR id it ja ko no nl-NL pt-BR pt-PT ru sv th tr zh-Hans zh-Hant".split(" ")
-
- # Allow creating screenshots for just one languages
- if options[:language] != nil
- languages.keep_if { |language|
- language.casecmp(options[:language]) == 0
- }
-
- # Don't clear, because we might just be fixing one locale
- should_clear_previous_screenshots = false
- end
-
- puts languages
-
- capture_ios_screenshots(
- test_without_building: true,
- derived_data_path: derived_data_path,
- languages: languages,
- clear_previous_screenshots: should_clear_previous_screenshots,
- )
- end
-
- #####################################################################################
- # create_promo_screenshots
- # -----------------------------------------------------------------------------------
- # This lane generates the promo screenshots.
- # Source plain screenshots are supposed to be in the screenshots_orig folder
- # If this folder doesn't exist, the system will ask to use the standard screenshot
- # folder. If the user confirms, the pictures in the screenshots folder will be
- # copied to a new screenshots_orig folder.
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane create_promo_screenshots
- #
- # Example:
- # bundle exec fastlane create_promo_screenshots
- #####################################################################################
- desc "Creates promo screenshots"
- lane :create_promo_screenshots do |options|
-
- # Run screenshots generator tool
- # All file paths are relative to the `fast file`.
- promo_screenshots(
- orig_folder: "screenshots",
- metadata_folder: "appstoreres/metadata",
- output_folder: File.join(Dir.pwd, "/promo_screenshots"),
- force: options[:force],
- )
- end
-
- #####################################################################################
- # download_promo_strings
- # -----------------------------------------------------------------------------------
- # This lane downloads the promo strings to use for the creation of the enhanced
- # screenshots.
- # -----------------------------------------------------------------------------------
- # Usage:
- # bundle exec fastlane download_promo_strings
- #
- # Example:
- # bundle exec fastlane download_promo_strings
- #####################################################################################
- desc "Downloads translated promo strings from GlotPress"
- lane :download_promo_strings do |options|
- files = {
- "app_store_screenshot-1" => {desc: "app_store_screenshot_2.txt"},
- "app_store_screenshot-2" => {desc: "app_store_screenshot_5.txt"},
- "app_store_screenshot-3" => {desc: "app_store_screenshot_3.txt"},
- "app_store_screenshot-4" => {desc: "app_store_screenshot_1.txt"},
- "app_store_screenshot-5" => {desc: "app_store_screenshot_4.txt"},
-
- "enhanced_app_store_screenshot-1" => {desc: "app_store_screenshot_1.html"},
- "enhanced_app_store_screenshot-2" => {desc: "app_store_screenshot_2.html"},
- "enhanced_app_store_screenshot-3" => {desc: "app_store_screenshot_3.html"},
- "enhanced_app_store_screenshot-4" => {desc: "app_store_screenshot_4.html"},
- "enhanced_app_store_screenshot-5" => {desc: "app_store_screenshot_5.html"},
- "enhanced_app_store_screenshot-6" => {desc: "app_store_screenshot_6.html"}
- }
-
- metadata_locales = [
- ["en-gb", "en-US"],
- ["en-gb", "en-GB"],
- ["en-ca", "en-CA"],
- ["en-au", "en-AU"],
- ["da", "da"],
- ["de", "de-DE"],
- ["es", "es-ES"],
- ["fr", "fr-FR"],
- ["id", "id"],
- ["it", "it"],
- ["ja", "ja"],
- ["ko", "ko"],
- ["nl", "nl-NL"],
- ["nb", "no"],
- ["pt-br", "pt-BR"],
- ["pt", "pt-PT"],
- ["ru", "ru"],
- ["sv", "sv"],
- ["th", "th"],
- ["tr", "tr"],
- ["zh-cn", "zh-Hans"],
- ["zh-tw", "zh-Hant"],
- ]
-
- gp_downloadmetadata(project_url: "https://translate.wordpress.org/projects/apps/ios/release-notes/",
- target_files: files,
- locales: metadata_locales,
- source_locale: "en-US",
- download_path: "./fastlane/appstoreres/metadata")
- end
-
-end
diff --git a/Scripts/fastlane/Snapfile b/Scripts/fastlane/Snapfile
deleted file mode 100644
index 84d79a4ebcd3..000000000000
--- a/Scripts/fastlane/Snapfile
+++ /dev/null
@@ -1,60 +0,0 @@
-# Uncomment the lines below you want to change by removing the # in the beginning
-# Verify script has credentials
-
-fastlane_directory = File.expand_path File.dirname(__FILE__)
-# __FILE__ can return different results when this file is required or used directly
-# This allows it to work in all cases
-if File.basename(fastlane_directory) != "fastlane"
- fastlane_directory = File.join fastlane_directory, "fastlane"
-end
-
-# A list of devices you want to take the screenshots from
-devices([
- "iPhone Xs Max",
- "iPhone 8 Plus",
- "iPad Pro (12.9-inch) (2nd generation)",
- "iPad Pro (12.9-inch) (3rd generation)",
-])
-
-# Where should the resulting screenshots be stored?
-output_directory File.join fastlane_directory, "screenshots"
-
-scheme "WordPressScreenshotGeneration"
-
-# clear_previous_screenshots true # remove the '#'' to clear all previously generated screenshots before creating new ones
-
-# Where is your project (or workspace)? Provide the full path here
-workspace File.join fastlane_directory, "../../WordPress.xcworkspace"
-
-# Since Fastlane searches recursively from the current directory for the helper (Scripts/ or fastlane/),
-# this check will always fail for us
-skip_helper_version_check true
-
-reinstall_app true
-erase_simulator true
-localize_simulator true
-concurrent_simulators false
-clear_previous_screenshots true
-
-# By default, the latest version should be used automatically. If you want to change it, do it here
-# ios_version '8.1'
-
-# Custom Callbacks
-
-# setup_for_device_change do |device|
-# puts "Preparing device: #{device}"
-# end
-
-# setup_for_language_change do |lang, device|
-# puts "Running #{lang} on #{device}"
-# system("./popuplateDatabase.sh")
-# end
-
-# teardown_language do |lang, device|
-# puts "Finished with #{lang} on #{device}"
-# end
-
-# teardown_device do |device|
-# puts "Cleaning device #{device}"
-# system("./cleanup.sh")
-# end
diff --git a/Scripts/fastlane/actions/ios_update_metadata_source.rb b/Scripts/fastlane/actions/ios_update_metadata_source.rb
deleted file mode 100644
index 1dfb0d4b847a..000000000000
--- a/Scripts/fastlane/actions/ios_update_metadata_source.rb
+++ /dev/null
@@ -1,83 +0,0 @@
-module Fastlane
- module Actions
- class IosUpdateMetadataSourceAction < Action
- def self.run(params)
- # Check local repo status
- other_action.ensure_git_status_clean()
-
- other_action.gp_update_metadata_source(po_file_path: params[:po_file_path],
- source_files: params[:source_files],
- release_version: params[:release_version])
-
- Action.sh("git add #{params[:po_file_path]}")
- params[:source_files].each do | key, file |
- Action.sh("git add #{file}")
- end
-
- repo_status = Actions.sh("git status --porcelain")
- repo_clean = repo_status.empty?
- if (!repo_clean) then
- Action.sh("git commit -m \"Update metadata strings\"")
- Action.sh("git push")
- end
- end
-
- #####################################################
- # @!group Documentation
- #####################################################
-
- def self.description
- "Updates the AppStoreStrings.po file with the data from text source files"
- end
-
- def self.details
- "Updates the AppStoreStrings.po file with the data from text source files"
- end
-
- def self.available_options
- # Define all options your action supports.
-
- # Below a few examples
- [
- FastlaneCore::ConfigItem.new(key: :po_file_path,
- env_name: "FL_IOS_UPDATE_METADATA_SOURCE_PO_FILE_PATH",
- description: "The path of the .po file to update",
- is_string: true,
- verify_block: proc do |value|
- UI.user_error!("No .po file path for UpdateMetadataSourceAction given, pass using `po_file_path: 'file path'`") unless (value and not value.empty?)
- UI.user_error!("Couldn't find file at path '#{value}'") unless File.exist?(value)
- end),
- FastlaneCore::ConfigItem.new(key: :release_version,
- env_name: "FL_IOS_UPDATE_METADATA_SOURCE_RELEASE_VERSION",
- description: "The release version of the app (to use to mark the release notes)",
- verify_block: proc do |value|
- UI.user_error!("No relase version for UpdateMetadataSourceAction given, pass using `release_version: 'version'`") unless (value and not value.empty?)
- end),
- FastlaneCore::ConfigItem.new(key: :source_files,
- env_name: "FL_IOS_UPDATE_METADATA_SOURCE_SOURCE_FILES",
- description: "The hash with the path to the source files and the key to use to include their content",
- is_string: false,
- verify_block: proc do |value|
- UI.user_error!("No source file hash for UpdateMetadataSourceAction given, pass using `source_files: 'source file hash'`") unless (value and not value.empty?)
- end)
- ]
- end
-
- def self.output
-
- end
-
- def self.return_value
-
- end
-
- def self.authors
- ["loremattei"]
- end
-
- def self.is_supported?(platform)
- platform == :ios
- end
- end
- end
-end
diff --git a/Scripts/fastlane/appstoreres/assets/ipad-pro.png b/Scripts/fastlane/appstoreres/assets/ipad-pro.png
deleted file mode 100644
index 53a8b3f910a8..000000000000
Binary files a/Scripts/fastlane/appstoreres/assets/ipad-pro.png and /dev/null differ
diff --git a/Scripts/fastlane/appstoreres/assets/iphone-8.png b/Scripts/fastlane/appstoreres/assets/iphone-8.png
deleted file mode 100644
index c780629f0227..000000000000
Binary files a/Scripts/fastlane/appstoreres/assets/iphone-8.png and /dev/null differ
diff --git a/Scripts/fastlane/appstoreres/assets/iphone-x.png b/Scripts/fastlane/appstoreres/assets/iphone-x.png
deleted file mode 100644
index 29f8428eb320..000000000000
Binary files a/Scripts/fastlane/appstoreres/assets/iphone-x.png and /dev/null differ
diff --git a/Scripts/fastlane/appstoreres/assets/style.css b/Scripts/fastlane/appstoreres/assets/style.css
deleted file mode 100644
index fca02b4cfbbf..000000000000
--- a/Scripts/fastlane/appstoreres/assets/style.css
+++ /dev/null
@@ -1,7 +0,0 @@
-*{
- font-family: 'NotoSans';
-}
-
-strong{
- font-family: 'NotoSans-Bold';
-}
diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_1.txt
deleted file mode 100644
index 219b92d6cf47..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Teil dein Ideen
-mit der Welt
diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_2.txt
deleted file mode 100644
index 178891bf8443..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Genieße deine
-Lieblings-Websites
diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.html b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.html
deleted file mode 100644
index 809fd09e8442..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.html
+++ /dev/null
@@ -1,2 +0,0 @@
-Prüfe in Echtzeit, was
-passiert
diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.txt
deleted file mode 100644
index f9ece98755c9..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Verwalte deine Website
-immer und überall
diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_4.txt
deleted file mode 100644
index d539b6396107..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Alle Statistiken
-in deiner Hand
diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_5.txt
deleted file mode 100644
index 89c731fa3722..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Erhalte
-Benachrichtigungen in Echtzeit
diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_1.txt
deleted file mode 100644
index f05f62365f9c..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Share your ideas
-with the world
diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_2.txt
deleted file mode 100644
index def49ec14b24..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Enjoy your
-favourite sites
diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_3.txt
deleted file mode 100644
index d59c84b907e9..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Manage your site
-everywhere you go
diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_4.txt
deleted file mode 100644
index 63b0655fe4b2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-All the stats
-in your hand
diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_5.txt
deleted file mode 100644
index f9384a7f75d8..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Get notified
-in real-time
diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_1.txt
deleted file mode 100644
index f05f62365f9c..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Share your ideas
-with the world
diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_2.txt
deleted file mode 100644
index def49ec14b24..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Enjoy your
-favourite sites
diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_3.txt
deleted file mode 100644
index d59c84b907e9..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Manage your site
-everywhere you go
diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_4.txt
deleted file mode 100644
index 63b0655fe4b2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-All the stats
-in your hand
diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_5.txt
deleted file mode 100644
index f9384a7f75d8..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Get notified
-in real-time
diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_6.html b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_6.html
deleted file mode 100644
index f41587280498..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_6.html
+++ /dev/null
@@ -1 +0,0 @@
-Write without compromises
diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_1.txt
deleted file mode 100644
index f05f62365f9c..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Share your ideas
-with the world
diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_2.txt
deleted file mode 100644
index def49ec14b24..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Enjoy your
-favourite sites
diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_3.txt
deleted file mode 100644
index d59c84b907e9..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Manage your site
-everywhere you go
diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_4.txt
deleted file mode 100644
index 63b0655fe4b2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-All the stats
-in your hand
diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_5.txt
deleted file mode 100644
index 41f0eeade0e1..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Get notified
-in real time
diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_1.txt
deleted file mode 100644
index f05f62365f9c..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Share your ideas
-with the world
diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_2.txt
deleted file mode 100644
index 6be99561adab..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Enjoy your
-favorite sites
diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_3.txt
deleted file mode 100644
index d59c84b907e9..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Manage your site
-everywhere you go
diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_4.txt
deleted file mode 100644
index 63b0655fe4b2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-All the stats
-in your hand
diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_5.txt
deleted file mode 100644
index f9384a7f75d8..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Get notified
-in real-time
diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_6.html b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_6.html
deleted file mode 100644
index f41587280498..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_6.html
+++ /dev/null
@@ -1 +0,0 @@
-Write without compromises
diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_1.txt
deleted file mode 100644
index 31d5d067bd62..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Comparte tus ideas
-con el mundo
diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.html b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.html
deleted file mode 100644
index 1c136a4f3a74..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.html
+++ /dev/null
@@ -1,2 +0,0 @@
-Analiza que les encanta
-a tus visitantes
diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.txt
deleted file mode 100644
index 150b0e7abd3a..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Disfruta de tus
-sitios favoritos
diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_3.txt
deleted file mode 100644
index 43cafbb68c03..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Gestiona tu sitio
-desde donde estés
diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_4.txt
deleted file mode 100644
index f93bd8587bff..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Todas las estadísticas
-en tu mano
diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_5.txt
deleted file mode 100644
index 199d03e49352..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Recibe notificaciones
-en tiempo real
diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_1.txt
deleted file mode 100644
index 6f3d361510b2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Partagez vos idées
-avec le monde entier
diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_2.txt
deleted file mode 100644
index a8f70d933e89..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Profitez de
-vos sites préférés
diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_3.txt
deleted file mode 100644
index a8c7e16d083e..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Gérez votre site
-où que vous soyez
diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_4.txt
deleted file mode 100644
index f2e996beefe7..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Toutes les statistiques
-à portée de main
diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_5.txt
deleted file mode 100644
index 8119ac950a4c..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Recevez les notifications
-en temps réel
diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_1.txt
deleted file mode 100644
index 12fe0921e1dd..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Bagikan ide Anda
-kepada dunia
diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_2.txt
deleted file mode 100644
index 40e19ba04a0e..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Nikmati
-situs favorit Anda
diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.html b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.html
deleted file mode 100644
index 27ef85ed423d..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.html
+++ /dev/null
@@ -1,2 +0,0 @@
-Ketahui kabar terkini
-secara real-time
diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.txt
deleted file mode 100644
index 6cc6996d4053..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Kelola situs Anda
-dari mana saja
diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_4.txt
deleted file mode 100644
index 9584564e95b2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Semua statistik
-dalam kendali Anda
diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_5.txt
deleted file mode 100644
index 3915cf816508..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Dapatkan pemberitahuan
-secara real-time
diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_1.txt
deleted file mode 100644
index 1dc10e52c2d0..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Condividi idee
-con tutto il mondo
diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_2.txt
deleted file mode 100644
index 8309cdf033c3..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Goditi i
-siti preferiti
diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_3.txt
deleted file mode 100644
index 430c02559daf..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Gestisci il sito
-ovunque tu vada
diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_4.txt
deleted file mode 100644
index d7d9cbbfbd2d..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Tutte le statistiche
-a portata di mano
diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_5.txt
deleted file mode 100644
index c460e6f6c455..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Notifiche in
-tempo reale
diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_1.txt
deleted file mode 100644
index b6a7a9e1cb11..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-アイディアを
-世界と共有
diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_2.txt
deleted file mode 100644
index 3051711fb9c2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-お気に入りの
-サイトを楽しむ
diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_3.txt
deleted file mode 100644
index 95a69882c50e..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-どこからでも
-サイトを管理
diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.html b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.html
deleted file mode 100644
index 3cd302344249..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.html
+++ /dev/null
@@ -1,2 +0,0 @@
-共有元
-制限なし
diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.txt
deleted file mode 100644
index fe97078b5d80..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-統計情報を
-いつでもチェック
diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.html b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.html
deleted file mode 100644
index e012799ba726..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.html
+++ /dev/null
@@ -1,2 +0,0 @@
-キャプチャ案
-外出先
diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.txt
deleted file mode 100644
index 0fa5e98dbf2e..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-リアルタイムに
-通知を受信
diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_1.txt
deleted file mode 100644
index 33fbca80dd17..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_1.txt
+++ /dev/null
@@ -1 +0,0 @@
-전 세계 사람들과 아이디어 공유
diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_2.txt
deleted file mode 100644
index 3006f35cee2f..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_2.txt
+++ /dev/null
@@ -1 +0,0 @@
-즐겨 찾는 사이트 즐기기
diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_3.txt
deleted file mode 100644
index 9c12bf253391..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_3.txt
+++ /dev/null
@@ -1 +0,0 @@
-어디에서든 사이트 관리
diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_4.txt
deleted file mode 100644
index 1028309d6c22..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_4.txt
+++ /dev/null
@@ -1 +0,0 @@
-모든 통계를 간편하게 확인
diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_5.txt
deleted file mode 100644
index 03ffb9d129f5..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_5.txt
+++ /dev/null
@@ -1 +0,0 @@
-실시간 알림 받기
diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_6.html b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_6.html
deleted file mode 100644
index 423330b3c25f..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_6.html
+++ /dev/null
@@ -1 +0,0 @@
-타협하지 않고 작성
diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_1.txt
deleted file mode 100644
index 1bda0b27f086..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Deel je ideeën
-met de wereld
diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_2.txt
deleted file mode 100644
index cdc021d4ba22..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Geniet van je
-favoriete sites
diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_3.txt
deleted file mode 100644
index 8e48d12d0447..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Beheer je site
-waar je ook bent
diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_4.txt
deleted file mode 100644
index 451d6cb05f0e..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Alle statistieken
-in je handpalm
diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_5.txt
deleted file mode 100644
index c3ea919918bd..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Ontvang
-realtime meldingen
diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_1.txt
deleted file mode 100644
index d7092d266d33..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Del dine ideer
-med verden
diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_2.txt
deleted file mode 100644
index 095a1dfa5f64..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Ha glede av dine
-favorittnettsteder
diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_3.txt
deleted file mode 100644
index 7d380691e1d2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Administrer ditt nettsted
-hvor enn du går
diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_4.txt
deleted file mode 100644
index 20672437f718..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-All statistikken
-i din hånd
diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_5.txt
deleted file mode 100644
index 9070936267dc..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Bli varslet
-i sanntid
diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_1.txt
deleted file mode 100644
index b60fe496fba4..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Compartilhe suas ideias
-com o mundo
diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_2.txt
deleted file mode 100644
index 04adaf2ffb8d..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Divirta-se com
-seus sites favoritos
diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_3.txt
deleted file mode 100644
index 74809ba7474a..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Gerencie seu site
-de qualquer lugar
diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_4.txt
deleted file mode 100644
index 2ad588d5428f..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Todos as estatísticas
-em suas mãos
diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_5.txt
deleted file mode 100644
index 4e0b2af21dec..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Notificações
-em tempo real
diff --git a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_1.txt
deleted file mode 100644
index a1fdb665884c..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Partilhe as suas ideias
-com o mundo
diff --git a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_4.txt
deleted file mode 100644
index 001d56880250..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Todas as estatísticas
-na sua mão
diff --git a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_5.txt
deleted file mode 100644
index 47de92a1dfac..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Receba notificações
-em tempo real
diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_1.txt
deleted file mode 100644
index 59900c172464..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Делитесь идеями
-с миром
diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_2.txt
deleted file mode 100644
index 41e4abe05b1c..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Наслаждайтесь
-любимыми сайтами
diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_3.txt
deleted file mode 100644
index e67131e7a5cd..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Управляйте сайтом
-отовсюду
diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_4.txt
deleted file mode 100644
index eeed21c0f3e2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Вся статистика
-в ваших руках
diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_5.txt
deleted file mode 100644
index 9adce6ad0dbe..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Получайте уведомления
-в реальном времени
diff --git a/Scripts/fastlane/appstoreres/metadata/source/description.txt b/Scripts/fastlane/appstoreres/metadata/source/description.txt
deleted file mode 100644
index d37d529106ec..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/source/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favorite photos and videos, view stats and reply to comments.
-
-With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from.
-
-WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/.
-
-WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher.
-
-Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS.\n
\ No newline at end of file
diff --git a/Scripts/fastlane/appstoreres/metadata/source/keywords.txt b/Scripts/fastlane/appstoreres/metadata/source/keywords.txt
deleted file mode 100644
index 9b6f8ad531d5..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/source/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design\n
\ No newline at end of file
diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_1.txt
deleted file mode 100644
index 5866dd31ce88..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Enjoy your
-favorite sites
\ No newline at end of file
diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_2.txt
deleted file mode 100644
index 46c365499355..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Get notified
-in real-time
\ No newline at end of file
diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_3.txt
deleted file mode 100644
index d6ef87810d30..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Manage your site
-everywhere you go
\ No newline at end of file
diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_4.txt
deleted file mode 100644
index 7bd2b359b0a9..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Share your ideas
-with the world
\ No newline at end of file
diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_5.txt
deleted file mode 100644
index 3e846b759f5a..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-All the stats
-in your hand
\ No newline at end of file
diff --git a/Scripts/fastlane/appstoreres/metadata/source/subtitle.txt b/Scripts/fastlane/appstoreres/metadata/source/subtitle.txt
deleted file mode 100644
index b34d71b64f35..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/source/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Manage your website anywhere
\ No newline at end of file
diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_1.txt
deleted file mode 100644
index 634a9cd85d5d..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Dela dina idéer
-med världen
diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_2.txt
deleted file mode 100644
index f99f138fe0b4..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Läs dina
-favoriter.
diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_3.txt
deleted file mode 100644
index 866e5098cbe0..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Hantera din webbplats
-varsomhelst
diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_4.txt
deleted file mode 100644
index 8e006bf59b15..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-All statistik
-till hands
diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_5.txt
deleted file mode 100644
index d345b679698b..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Få notiser
-i realtid
diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_1.txt
deleted file mode 100644
index 6213e5d92728..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Fikirlerinizi dünya
-ile paylaşın
diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_2.txt
deleted file mode 100644
index 65f26734f78f..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Beğendiğiniz sitelerin
-tadını çıkarın
diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_3.txt
deleted file mode 100644
index 72f1ba4d4c5c..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Gittiğiniz her yerde
-sitenizi yönetin
diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_4.txt
deleted file mode 100644
index 3b773e4e2345..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Tüm istatistikler
-avucunuzda
diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_5.txt
deleted file mode 100644
index af489559eaec..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Gerçek zamanlı
-olarak bildirim alın
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.html b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.html
deleted file mode 100644
index adf9f281b0de..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.html
+++ /dev/null
@@ -1,2 +0,0 @@
-创建精美的
-文章和页面
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.txt
deleted file mode 100644
index 089802b482b4..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-与全世界
-分享您的观点
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.html b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.html
deleted file mode 100644
index 50abbbe3d2b4..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.html
+++ /dev/null
@@ -1,2 +0,0 @@
-跟踪
-访客喜好
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.txt
deleted file mode 100644
index fbde724420ba..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-尽情访问
-您喜爱的站点
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.html b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.html
deleted file mode 100644
index e2da0c01b9c3..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.html
+++ /dev/null
@@ -1,2 +0,0 @@
-实时查看
-动态
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.txt
deleted file mode 100644
index 621eaf28020b..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-随时随地
-管理您的站点
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_4.txt
deleted file mode 100644
index 66947743ca56..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-掌握所有
-统计信息
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_5.txt
deleted file mode 100644
index 4c59907f5b63..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-实时
-获取通知
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_6.html b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_6.html
deleted file mode 100644
index 1166dd9acf0d..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_6.html
+++ /dev/null
@@ -1 +0,0 @@
-轻松撰写,自由无阻
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_1.txt
deleted file mode 100644
index fa8b7484f5e4..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_1.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-和全世界
-分享你的想法
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_2.txt
deleted file mode 100644
index bfd0d3eac94d..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_2.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-盡情瀏覽
-你喜愛的網站
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_3.txt
deleted file mode 100644
index b3bb3cffabe2..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_3.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-隨時隨地
-管理你的網站
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_4.txt
deleted file mode 100644
index bdef77677f76..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_4.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-所有統計資料
-盡在你掌握
diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_5.txt
deleted file mode 100644
index 3bcf65e4d957..000000000000
--- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_5.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-即時收到
-通知
diff --git a/Scripts/fastlane/download_metadata.swift b/Scripts/fastlane/download_metadata.swift
deleted file mode 100755
index 735856467129..000000000000
--- a/Scripts/fastlane/download_metadata.swift
+++ /dev/null
@@ -1,146 +0,0 @@
-#!/usr/bin/env swift
-
-import Foundation
-
-let glotPressSubtitleKey = "app_store_subtitle"
-let glotPressWhatsNewKey = "v14.3-whats-new"
-let glotPressDescriptionKey = "app_store_desc"
-let glotPressKeywordsKey = "app_store_keywords"
-let baseFolder = "./metadata"
-
-// iTunes Connect language code: GlotPress code
-let languages = [
- "da": "da",
- "de-DE": "de",
- "en-AU": "en-au",
- "en-CA": "en-ca",
- "en-GB": "en-gb",
- "default": "en-us", // Technically not a real GlotPress language
- "en-US": "en-us", // Technically not a real GlotPress language
- "es-ES": "es",
- "fr-FR": "fr",
- "id": "id",
- "it": "it",
- "ja": "ja",
- "ko": "ko",
- "nl-NL": "nl",
- "no": "nb",
- "pt-BR": "pt-br",
- "pt-PT": "pt",
- "ru": "ru",
- "sv": "sv",
- "th": "th",
- "tr": "tr",
- "zh-Hans": "zh-cn",
- "zh-Hant": "zh-tw",
-]
-
-func downloadTranslation(languageCode: String, folderName: String) {
- let languageCodeOverride = languageCode == "en-us" ? "en-gb" : languageCode
- let glotPressURL = "https://translate.wordpress.org/projects/apps/ios/release-notes/\(languageCodeOverride)/default/export-translations?format=json"
- let requestURL: URL = URL(string: glotPressURL)!
- let urlRequest: URLRequest = URLRequest(url: requestURL)
- let session = URLSession.shared
-
- let sema = DispatchSemaphore( value: 0)
-
- print("Downloading Language: \(languageCode)")
-
- let task = session.dataTask(with: urlRequest) {
- (data, response, error) -> Void in
-
- defer {
- sema.signal()
- }
-
- guard let data = data else {
- print(" Invalid data downloaded.")
- return
- }
-
- guard let json = try? JSONSerialization.jsonObject(with: data, options: []),
- let jsonDict = json as? [String: Any] else {
- print(" JSON was not returned")
- return
- }
-
- var subtitle: String?
- var whatsNew: String?
- var keywords: String?
- var storeDescription: String?
-
- jsonDict.forEach({ (key: String, value: Any) in
-
- guard let index = key.index(of: Character(UnicodeScalar(0004))) else {
- return
- }
-
- let keyFirstPart = String(key[.. 0 {
- try marketingURL.write(toFile: "\(languageFolder)/marketing_url.txt", atomically: true, encoding: .utf8)
- }
- } catch {
- print(" Error writing: \(error)")
- }
- }
-
- task.resume()
- sema.wait()
-}
-
-func deleteExistingMetadata() {
- let fileManager = FileManager.default
- let url = URL(fileURLWithPath: baseFolder, isDirectory: true)
- try? fileManager.removeItem(at: url)
- try? fileManager.createDirectory(at: url, withIntermediateDirectories: false)
-}
-
-
-deleteExistingMetadata()
-
-languages.forEach( { (key: String, value: String) in
- downloadTranslation(languageCode: value, folderName: key)
-})
-
diff --git a/Scripts/fastlane/metadata/de-DE/description.txt b/Scripts/fastlane/metadata/de-DE/description.txt
deleted file mode 100644
index 807442f80457..000000000000
--- a/Scripts/fastlane/metadata/de-DE/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Verwalte oder kreiere deinen WordPress-Blog oder deine Website direkt auf deinem iOS-Gerät. Erstelle und editiere Beiträge und Seiten, lade deine Liebelingsfotos und Videos hoch, sieh dir Statistiken an und antworte auf Kommentare.
-
-Mit WordPress für iOS hältst du das Werkzeug, um Texte zu veröffentlichen direkt in deiner Hand. Entwirf einen spontanen Haiku auf deiner Couch. Schieß ein Foto in der Mittagspause und zeig es allen. Antworte auf einen neuen Kommentar oder schau dir an, aus welchen Ländern deine neuesten Besucher kommen.
-
-WordPress für iOS ist ein OpenSource-Projekt, an dessen Entwicklung du dich beteiligen kannst. Um mehr zu erfahren, schau unter https://apps.wordpress.com/contribute/ nach.
-
-WordPress für iOS unterstützt WordPress.com und selbst gehostete WordPress.org-Websites ab WordPress-Version 4.0.
-
-Wenn du Hilfe zur App brauchst, findest du Informationen im den Foren unter https://ios.forums.wordpress.org/. Du kannst aber auch einen Tweet an us @WordPressiOS schicken.
-
diff --git a/Scripts/fastlane/metadata/de-DE/keywords.txt b/Scripts/fastlane/metadata/de-DE/keywords.txt
deleted file mode 100644
index 8961611da89b..000000000000
--- a/Scripts/fastlane/metadata/de-DE/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-sozial,netzwerk,notizen,jetpack,fotos,schreiben,geotagging,medien,blog,wordpress,website,design
diff --git a/Scripts/fastlane/metadata/de-DE/marketing_url.txt b/Scripts/fastlane/metadata/de-DE/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/de-DE/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/de-DE/subtitle.txt b/Scripts/fastlane/metadata/de-DE/subtitle.txt
deleted file mode 100644
index b22aef49d3a7..000000000000
--- a/Scripts/fastlane/metadata/de-DE/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Verwalte deine Website überall
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/default/description.txt b/Scripts/fastlane/metadata/default/description.txt
deleted file mode 100644
index 5b077b5f6cc1..000000000000
--- a/Scripts/fastlane/metadata/default/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favorite photos and videos, view stats and reply to comments.
-
-With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from.
-
-WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/.
-
-WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher.
-
-Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS.
-
diff --git a/Scripts/fastlane/metadata/default/keywords.txt b/Scripts/fastlane/metadata/default/keywords.txt
deleted file mode 100644
index ab6cbfc0f9a1..000000000000
--- a/Scripts/fastlane/metadata/default/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design
diff --git a/Scripts/fastlane/metadata/default/marketing_url.txt b/Scripts/fastlane/metadata/default/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/default/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/default/release_notes.txt b/Scripts/fastlane/metadata/default/release_notes.txt
deleted file mode 100644
index 97442e5c2f83..000000000000
--- a/Scripts/fastlane/metadata/default/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Commenting:
-- Fixed a bug causing text selection to happen on the wrong line when editing comments.
-- Fixed a bug causing HTML markup to display in comment content.
-- Fixed an issue causing comments not to appear in the Reader.
-
-Publishing:
-- You can now crop, zoom in/out, and rotate images in a post.
-- The app now includes a desktop preview mode on iPhone, and Mobile preview on iPad. Post preview also has new navigation, “Open in Safari,” and Share options.
-- Lots of block editor improvements: Added a long-press icon for inserting blocks before/after and an “Edit” button overlay on selected image blocks. The editor will retry to load images after connectivity issues. And the Gallery block now has support for image size options.
-- We fixed a bug that could disable comments on a draft post when previewing it.
-
-Signup and Login
-- Signup or login via magic link now supports multiple email clients. Tapping on the “Open Email” button will present a list of installed email clients to choose from.
-
-Reader
-- The apps now support post reblogging! Tap the new “reblog” button in the post action bar to choose which of your sites to post to and open the editor of your choice with pre-populated content from the original post.
diff --git a/Scripts/fastlane/metadata/default/subtitle.txt b/Scripts/fastlane/metadata/default/subtitle.txt
deleted file mode 100644
index b34d71b64f35..000000000000
--- a/Scripts/fastlane/metadata/default/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Manage your website anywhere
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/en-AU/description.txt b/Scripts/fastlane/metadata/en-AU/description.txt
deleted file mode 100644
index 4e4165e37344..000000000000
--- a/Scripts/fastlane/metadata/en-AU/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favourite photos and videos, view stats and reply to comments.
-
-With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from.
-
-WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/.
-
-WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher.
-
-Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS.
-
diff --git a/Scripts/fastlane/metadata/en-AU/keywords.txt b/Scripts/fastlane/metadata/en-AU/keywords.txt
deleted file mode 100644
index ab6cbfc0f9a1..000000000000
--- a/Scripts/fastlane/metadata/en-AU/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design
diff --git a/Scripts/fastlane/metadata/en-AU/marketing_url.txt b/Scripts/fastlane/metadata/en-AU/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/en-AU/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/en-AU/subtitle.txt b/Scripts/fastlane/metadata/en-AU/subtitle.txt
deleted file mode 100644
index b34d71b64f35..000000000000
--- a/Scripts/fastlane/metadata/en-AU/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Manage your website anywhere
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/en-CA/description.txt b/Scripts/fastlane/metadata/en-CA/description.txt
deleted file mode 100644
index 4e4165e37344..000000000000
--- a/Scripts/fastlane/metadata/en-CA/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favourite photos and videos, view stats and reply to comments.
-
-With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from.
-
-WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/.
-
-WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher.
-
-Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS.
-
diff --git a/Scripts/fastlane/metadata/en-CA/keywords.txt b/Scripts/fastlane/metadata/en-CA/keywords.txt
deleted file mode 100644
index ab6cbfc0f9a1..000000000000
--- a/Scripts/fastlane/metadata/en-CA/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design
diff --git a/Scripts/fastlane/metadata/en-CA/marketing_url.txt b/Scripts/fastlane/metadata/en-CA/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/en-CA/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/en-CA/subtitle.txt b/Scripts/fastlane/metadata/en-CA/subtitle.txt
deleted file mode 100644
index b34d71b64f35..000000000000
--- a/Scripts/fastlane/metadata/en-CA/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Manage your website anywhere
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/en-GB/description.txt b/Scripts/fastlane/metadata/en-GB/description.txt
deleted file mode 100644
index 4e4165e37344..000000000000
--- a/Scripts/fastlane/metadata/en-GB/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favourite photos and videos, view stats and reply to comments.
-
-With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from.
-
-WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/.
-
-WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher.
-
-Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS.
-
diff --git a/Scripts/fastlane/metadata/en-GB/keywords.txt b/Scripts/fastlane/metadata/en-GB/keywords.txt
deleted file mode 100644
index ab6cbfc0f9a1..000000000000
--- a/Scripts/fastlane/metadata/en-GB/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design
diff --git a/Scripts/fastlane/metadata/en-GB/marketing_url.txt b/Scripts/fastlane/metadata/en-GB/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/en-GB/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/en-GB/release_notes.txt b/Scripts/fastlane/metadata/en-GB/release_notes.txt
deleted file mode 100644
index ba17bc253b3f..000000000000
--- a/Scripts/fastlane/metadata/en-GB/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Commenting:
-- Fixed a bug causing text selection to happen on the wrong line when editing comments.
-- Fixed a bug causing HTML markup to display in comment content.
-- Fixed an issue causing comments not to appear in the Reader.
-
-Publishing:
-- You can now crop, zoom in/out and rotate images in a post.
-- The app now includes a desktop preview mode on iPhone and Mobile preview on iPad. Post preview also has new navigation, “Open in Safari,” and Share options.
-- Lots of block editor improvements: added a long-press icon for inserting blocks before/after and an “Edit” button overlay on selected image blocks. The editor will retry to load images after connectivity issues. And the Gallery block now has support for image size options.
-- We fixed a bug that could disable comments on a draft post when previewing it.
-
-Signup and Login
-- Signup or login via magic link now supports multiple email clients. Tapping on the “Open Email” button will present a list of installed email clients to choose from.
-
-Reader
-- The apps now support post reblogging! Tap the new “reblog” button in the post action bar to choose which of your sites to post to and open the editor of your choice with pre-populated content from the original post.
diff --git a/Scripts/fastlane/metadata/en-GB/subtitle.txt b/Scripts/fastlane/metadata/en-GB/subtitle.txt
deleted file mode 100644
index b34d71b64f35..000000000000
--- a/Scripts/fastlane/metadata/en-GB/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Manage your website anywhere
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/en-US/description.txt b/Scripts/fastlane/metadata/en-US/description.txt
deleted file mode 100644
index 5b077b5f6cc1..000000000000
--- a/Scripts/fastlane/metadata/en-US/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favorite photos and videos, view stats and reply to comments.
-
-With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from.
-
-WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/.
-
-WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher.
-
-Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS.
-
diff --git a/Scripts/fastlane/metadata/en-US/keywords.txt b/Scripts/fastlane/metadata/en-US/keywords.txt
deleted file mode 100644
index ab6cbfc0f9a1..000000000000
--- a/Scripts/fastlane/metadata/en-US/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design
diff --git a/Scripts/fastlane/metadata/en-US/marketing_url.txt b/Scripts/fastlane/metadata/en-US/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/en-US/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/en-US/release_notes.txt b/Scripts/fastlane/metadata/en-US/release_notes.txt
deleted file mode 100644
index 97442e5c2f83..000000000000
--- a/Scripts/fastlane/metadata/en-US/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Commenting:
-- Fixed a bug causing text selection to happen on the wrong line when editing comments.
-- Fixed a bug causing HTML markup to display in comment content.
-- Fixed an issue causing comments not to appear in the Reader.
-
-Publishing:
-- You can now crop, zoom in/out, and rotate images in a post.
-- The app now includes a desktop preview mode on iPhone, and Mobile preview on iPad. Post preview also has new navigation, “Open in Safari,” and Share options.
-- Lots of block editor improvements: Added a long-press icon for inserting blocks before/after and an “Edit” button overlay on selected image blocks. The editor will retry to load images after connectivity issues. And the Gallery block now has support for image size options.
-- We fixed a bug that could disable comments on a draft post when previewing it.
-
-Signup and Login
-- Signup or login via magic link now supports multiple email clients. Tapping on the “Open Email” button will present a list of installed email clients to choose from.
-
-Reader
-- The apps now support post reblogging! Tap the new “reblog” button in the post action bar to choose which of your sites to post to and open the editor of your choice with pre-populated content from the original post.
diff --git a/Scripts/fastlane/metadata/en-US/subtitle.txt b/Scripts/fastlane/metadata/en-US/subtitle.txt
deleted file mode 100644
index b34d71b64f35..000000000000
--- a/Scripts/fastlane/metadata/en-US/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Manage your website anywhere
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/es-ES/description.txt b/Scripts/fastlane/metadata/es-ES/description.txt
deleted file mode 100644
index 0ae2ce09c78d..000000000000
--- a/Scripts/fastlane/metadata/es-ES/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-Crea y administra tu sitio web o blog de WordPress directamente desde tu dispositivo iOS: crea y edita cualquier entrada o página, sube tus fotos y videos favoritos, accede a las estadísticas o responde a los comentarios fácilmente.
-
-Con WordPress para iOS, podrás publicar todo tu contenido desde la palma de tu mano. Escribe desde tu sofá el borrador de ese haiku que no te quitas de la cabeza. Sube y comparte en un momento esa increíble foto que hiciste durante el descanso en el trabajo. Responde a los últimos comentarios que te hayan dejado o descubre a qué nuevos países ha llegado hoy tu contenido echándole un vistazo a la sección de estadísticas.
-
-WordPress para iOS es un proyecto de código abierto, lo que significa que tú también puedes participar en su desarrollo. Descubre más información en https://apps.wordpress.com/contribute/..
-
-WordPress para iOS es compatible con sitios de WordPress.com y sitios WordPress.org autoalojadas que tengan WordPress 4.0 o superior.
-
-¿Necesitas ayuda con la aplicación? Entra en el foro de ayuda https://ios.forums.wordpress.org/ o déjanos un tweet en @WordPressiOS.
diff --git a/Scripts/fastlane/metadata/es-ES/keywords.txt b/Scripts/fastlane/metadata/es-ES/keywords.txt
deleted file mode 100644
index 149308fa6dd7..000000000000
--- a/Scripts/fastlane/metadata/es-ES/keywords.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-red,social,notas,jetpack,fotos,escribir,geotagging,media,blog,wordpress,web,blogging,diseño
-
diff --git a/Scripts/fastlane/metadata/es-ES/marketing_url.txt b/Scripts/fastlane/metadata/es-ES/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/es-ES/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/es-ES/release_notes.txt b/Scripts/fastlane/metadata/es-ES/release_notes.txt
deleted file mode 100644
index e697649b6f5e..000000000000
--- a/Scripts/fastlane/metadata/es-ES/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Comentarios:
-- Se ha corregido un fallo que hacía que la selección de texto se hiciera en la línea errónea al editar los comentarios.
-- Se ha corregido un fallo que hacía que el marcado HTML se mostrara en el contenido de los comentarios.
-- Se ha corregido un problema que hacía que los comentarios no aparecieran en el lector.
-
-Publicación:
-- Ahora, puedes recortar, acercar/alejar y rotar las imágenes en una entrada.
-- Ahora, la aplicación incluye un modo de vista previa del escritorio en iPhone y una vista previa móvil en iPad. La vista previa de las entradas también tiene las nuevas opciones de navegación «Abrir en Safari» y «Compartir».
-- Muchas mejoras en el editor de bloques: Se ha añadido un icono de pulsación larga para insertar bloques antes/después y la superposición de un botón «Editar» en los bloques de imágenes seleccionados. El editor volverá a intentar cargar las imágenes después de los problemas de conectividad. Y el bloque de galería tiene ahora compatibilidad para opciones de tamaño de imágenes.
-- Hemos corregido un fallo que podía desactivar los comentarios en un borrador de una entrada al previsualizarla.
-
-Registro y acceso
-- Ahora, el registro y acceso a través de un enlace mágico es compatible con múltiples clientes de correo electrónico. Al tocar el botón «Abrir el correo electrónico», se presentará una lista de clientes de correo electrónico instalados para elegir.
-
-Lector
-- ¡La aplicación es compatible ahora con el reblogueo de las entradas! Toca el nuevo botón «Rebloguear» en la barra de acción de la entrada para elegir en cuál de tus sitios publicar y abrir el editor de tu elección con el contenido prerellenado de la entrada original.
diff --git a/Scripts/fastlane/metadata/es-ES/subtitle.txt b/Scripts/fastlane/metadata/es-ES/subtitle.txt
deleted file mode 100644
index 06f43a4782f3..000000000000
--- a/Scripts/fastlane/metadata/es-ES/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Publica en cualquier parte
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/fr-FR/description.txt b/Scripts/fastlane/metadata/fr-FR/description.txt
deleted file mode 100644
index ebcf5e4f5b50..000000000000
--- a/Scripts/fastlane/metadata/fr-FR/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-Gérez ou créez votre blog ou site WordPress directement depuis votre appareil iOS : créez et modifiez vos articles et pages, téléversez vos photos et vidéos préférées, regardez vos statistiques et répondez aux commentaires.
-
-Avec WordPress pour iOS, vous avez le pouvoir de publier dans le creux de la main. Griffonnez un petit poème sur le pouce. Prenez une photo et publiez-la à la pause déjeuner. Répondez aux derniers commentaires ou vérifiez vos statistiques pour voir de quel pays proviennent vos visiteurs du jour.
-
-WordPress pour iOS est un projet Open Source, ce qui veut dire que vous aussi vous pouvez contribuer à son développement. Apprenez-en plus sur https://apps.wordpress.com/contribute/.
-
-WordPress pour iOS fonctionne avec les sites WordPress.com et auto-hébergés tournant sous WordPress 4.0 ou ultérieurs.
-
-Besoin d’aide avec l’app ? Visitez le forum sur https://ios.forums.wordpress.org/ ou envoyez-nous un tweet sur @WordPressiOS.
diff --git a/Scripts/fastlane/metadata/fr-FR/keywords.txt b/Scripts/fastlane/metadata/fr-FR/keywords.txt
deleted file mode 100644
index b31f09448e74..000000000000
--- a/Scripts/fastlane/metadata/fr-FR/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,réseau,notes,jetpack,photos,écriture,geotagging,média,blog,wordpress,website,blogging,design
diff --git a/Scripts/fastlane/metadata/fr-FR/marketing_url.txt b/Scripts/fastlane/metadata/fr-FR/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/fr-FR/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/fr-FR/release_notes.txt b/Scripts/fastlane/metadata/fr-FR/release_notes.txt
deleted file mode 100644
index b6a9b7bc0c38..000000000000
--- a/Scripts/fastlane/metadata/fr-FR/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Commenter :
-- Correction d’un bug qui sélectionnait la mauvaise ligne lors de l’édition de commentaires.
-- Correction d’un bug qui affichait le balisage HTML dans le contenu d’un commentaire.
-- Correction d’un problème qui n’affichait plus les commentaires dans le Lecteur.
-
-Publication :
-- vous pouvez à présent recadrer, zoomer en avant/arrière et tourner les images dans un article.
-- L’app inclus maintenant un mode d’aperçu bureau sur iPhone et d’aperçu mobile sur iPad. L’aperçu d’article obtient aussi nouvelle navigation, « Ouvrir dans Safari » et des options de partage.
-- Beaucoup d’amélioration de l’éditeur de bloc : Ajout via une longue pression d’une icône pour insérer des blocs avant/ après et un bouton « Modification » en superposition sur un bloc image sélectionné. L’éditeur réessaiera de charger les images après un problème de connexion. Et la galerie de blocs prend en charge maintenant les options de taille d’image.
-- Nous avons corrigé un bug qui pouvait désactiver les commentaires sur un brouillon d’article lors de son aperçu.
-
-Inscription et connexion :
-- L’inscription et la connexion via lien magique prend en charge plusieurs apps de messagerie. Toucher le bouton « Ouvrir l’e-mail » vous présentera un liste d’apps d’e-mails installé à choisir.
-
-Lecteur :
-- Les apps prennent maintenant en charge le reblogue d’articles ! Toucher le nouveau bouton « Rebloguer » dans la barre d’actions de l’article pour choisir un de vos sites sur lequel publier et ouvrir l’éditeur de votre choix avec le contenu collé depuis l’article original.
diff --git a/Scripts/fastlane/metadata/fr-FR/subtitle.txt b/Scripts/fastlane/metadata/fr-FR/subtitle.txt
deleted file mode 100644
index 4a257e4502ed..000000000000
--- a/Scripts/fastlane/metadata/fr-FR/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Gérez votre site de partout
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/id/description.txt b/Scripts/fastlane/metadata/id/description.txt
deleted file mode 100644
index 7091ba14657a..000000000000
--- a/Scripts/fastlane/metadata/id/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Kelola atau buat blog atau situs web WordPress Anda dari perangkat iOS: buat dan sunting pos serta halaman, unggah foto dan video favorit, lihat statistik, dan balas komentar.
-
-Dengan WordPress untuk iOS, Anda memiliki kekuatan untuk memublikasikan dari telapak tangan Anda. Rangkai haiku secara spontan dari sofa Anda. Ambil dan pos foto saat istirahat makan siang. Tanggapi komentar terbaru, atau periksa statistik untuk melihat dari negara mana saja pengunjung situs Anda hari ini.
-
-WordPress untuk iOS adalah proyek Sumber Terbuka, yang berarti Anda juga bisa memberikan kontribusi dalam pengembangannya. Pelajari selengkapnya di https://apps.wordpress.com/contribute/.
-
-WordPress untuk iOS mendukung WordPress.com dan situs WordPress.org yang dihosting sendiri yang menjalankan WordPress versi 4.0 atau versi lebih tinggi.
-
-Butuh bantuan terkait aplikasi? Kunjungi forum di https://ios.forums.wordpress.org/ atau tweet kami di @WordPressiOS.
-
diff --git a/Scripts/fastlane/metadata/id/keywords.txt b/Scripts/fastlane/metadata/id/keywords.txt
deleted file mode 100644
index b19068fd8fcf..000000000000
--- a/Scripts/fastlane/metadata/id/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-sosial,jaringan,catatan,jetpack,foto,tulisan,geotagging,media,blog,wordpress,situs web,blog,desain
diff --git a/Scripts/fastlane/metadata/id/marketing_url.txt b/Scripts/fastlane/metadata/id/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/id/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/id/subtitle.txt b/Scripts/fastlane/metadata/id/subtitle.txt
deleted file mode 100644
index fb05e64587e6..000000000000
--- a/Scripts/fastlane/metadata/id/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Kelola situs Anda di mana saja
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/it/description.txt b/Scripts/fastlane/metadata/it/description.txt
deleted file mode 100644
index 330ee3c96310..000000000000
--- a/Scripts/fastlane/metadata/it/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-Gestisci o crea il tuo sito web o blog WordPress direttamente dal tuo dispositivo iOS: Crea e modifica articoli e pagine, carica foto e video preferiti, visualizza le statistiche e rispondi ai commenti.
-
-Con WordPress per iOS, hai il potere di pubblicare sul palmo della tua mano. Crea una bozza di un haiku spontaneo dal divano. Scatta e supplica una foto della tua pausa pranzo. Rispondi agli ultimi commenti o controlla le statistiche per scoprire da quale paese provengono i nuovi visitatori di oggi.
-
-WordPress per iOS è un progetto Open Source, il che significa che anche tu puoi contribuire al suo sviluppo. Scopri di più all’indirizzo https://apps.wordpress.com/contribute/.
-
-WordPress per iOS supporta WordPress.com e i siti WordPress.org in self-hosting che eseguono WordPress 4.0 o versione successiva.
-
-Hai bisogno di aiuto con l’app? Visita i forum all’indirizzo https://ios.forums.wordpress.org/ o mandaci un tweet a @WordPressiOS.
diff --git a/Scripts/fastlane/metadata/it/keywords.txt b/Scripts/fastlane/metadata/it/keywords.txt
deleted file mode 100644
index 66c1f8b6e0f4..000000000000
--- a/Scripts/fastlane/metadata/it/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-Social,network,note,jetpack,foto,scrittura,geotag,media,blog,wordpress,sito web,blog,design
diff --git a/Scripts/fastlane/metadata/it/marketing_url.txt b/Scripts/fastlane/metadata/it/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/it/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/it/subtitle.txt b/Scripts/fastlane/metadata/it/subtitle.txt
deleted file mode 100644
index 641897ee4f19..000000000000
--- a/Scripts/fastlane/metadata/it/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Gestisci il tuo sito ovunque
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/ja/description.txt b/Scripts/fastlane/metadata/ja/description.txt
deleted file mode 100644
index 8cdcc58422bf..000000000000
--- a/Scripts/fastlane/metadata/ja/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-iOS 端末から WordPress ブログとサイトを管理または作成しましょう。投稿やページの作成と編集、お気に入りの写真と動画のアップロード、統計の表示、コメントへの返信が可能です。
-
-WordPress for iOS を使えばスマートフォンから投稿を公開できます。くつろぎながら、頭に浮かんだ言葉をさっと下書きできます。お昼休みに写真を撮って投稿できます。最新のコメントに返信し、統計情報画面から初アクセスがあった国を確認できます。
-
-WordPress for iOS はオープンソースのプロジェクトですので、誰でも開発に貢献できます。詳しくは https://apps.wordpress.com/contribute/ をご覧ください。
-
-WordPress for iOS は、WordPress.com と、WordPress 4.0以降が稼働するインストール型 WordPress.org のサイトに対応しています。
-
-アプリに関するサポートが必要なときは、https://ios.forums.wordpress.org/ にアクセスするか、@WordPressiOS にツイートしてください。
diff --git a/Scripts/fastlane/metadata/ja/keywords.txt b/Scripts/fastlane/metadata/ja/keywords.txt
deleted file mode 100644
index aa64d7de3f89..000000000000
--- a/Scripts/fastlane/metadata/ja/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-ソーシャル,ネットワーク,メモ,jetpack,写真,投稿,位置情報,メディア,ブログ,wordpress,サイト,ブログ作成,デザイン
diff --git a/Scripts/fastlane/metadata/ja/marketing_url.txt b/Scripts/fastlane/metadata/ja/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/ja/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/ja/subtitle.txt b/Scripts/fastlane/metadata/ja/subtitle.txt
deleted file mode 100644
index 2e64a5e683ca..000000000000
--- a/Scripts/fastlane/metadata/ja/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-どこからでもサイトを管理
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/ko/description.txt b/Scripts/fastlane/metadata/ko/description.txt
deleted file mode 100644
index 13d8f6323a2c..000000000000
--- a/Scripts/fastlane/metadata/ko/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-iOS 기기에서 바로 워드프레스 블로그 또는 웹사이트를 관리하거나 만드세요. 글과 페이지를 만들고 편집하며, 좋아하는 사진과 비디오를 업로드하며, 통계를 보고 댓글에 답하세요.
-
-iOS용 워드프레스를 사용하여 사용자가 직접 발행할 수 있습니다. 소파에서 즉흥적으로 짧은 시를 써 보세요. 점심시간에 사진을 찍어 게시해 보세요. 최근 댓글에 답하거나, 통계를 보고 오늘 방문자가 어느 나라에서 새로 방문했는지 확인해 보세요.
-
-iOS용 워드프레스는 오픈 소스 프로젝트이므로 사용자도 개발에 참여할 수 있습니다. https://apps.wordpress.com/contribute/에서 자세히 알아보세요.
-
-iOS용 워드프레스는 워드프레스닷컴과 워드프레스 4.0 이상을 실행하는 자체 호스팅된 WordPress.org 사이트를 지원합니다.
-
-앱 사용에 도움이 필요하세요? https://ios.forums.wordpress.org/에서 포럼을 방문하거나 @WordPressiOS로 트윗하세요.
diff --git a/Scripts/fastlane/metadata/ko/keywords.txt b/Scripts/fastlane/metadata/ko/keywords.txt
deleted file mode 100644
index 77cafaca5395..000000000000
--- a/Scripts/fastlane/metadata/ko/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-소셜,네트워크,메모,젯팩,사진,쓰기,지오태그,미디어,블로그,워드프레스,웹사이트,블로깅,디자인
diff --git a/Scripts/fastlane/metadata/ko/marketing_url.txt b/Scripts/fastlane/metadata/ko/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/ko/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/ko/subtitle.txt b/Scripts/fastlane/metadata/ko/subtitle.txt
deleted file mode 100644
index 44ebd61aca3b..000000000000
--- a/Scripts/fastlane/metadata/ko/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-어디서든 웹사이트를 관리
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/nl-NL/description.txt b/Scripts/fastlane/metadata/nl-NL/description.txt
deleted file mode 100644
index dbbb8a8e643a..000000000000
--- a/Scripts/fastlane/metadata/nl-NL/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-Beheer of maak je WordPress-blog of -website direct vanaf je iOS-device: maak en bewerk berichten en pagina's, upload je favoriete foto's en video's, bekijk statistieken en plaats reacties op opmerkingen.
-
-Met WordPress voor iOS heb je publicatiemogelijkheden in de palm van je hand. Schrijf een spontane haiku vanaf je bank. Maak een foto tijdens je lunchpauze en upload deze. Reageer op de laatste opmerkingen of houd je statistieken in de gaten om te zien uit welke landen de bezoekers van vandaag komen.
-
-WordPress voor iOS is een opensourceproject; dat betekent dat ook jij kunt helpen bij de ontwikkeling ervan. Ga naar https://apps.wordpress.com/contribute/ voor meer informatie.
-
-WordPress voor iOS ondersteunt WordPress.com en zelf-gehoste WordPress.org-sites die op WordPress 4.0 of nieuwer worden uitgevoerd.
-
-Hulp nodig bij de app? Bezoek de forums op https://ios.forums.wordpress.org/ of tweet ons @WordPressiOS.
diff --git a/Scripts/fastlane/metadata/nl-NL/keywords.txt b/Scripts/fastlane/metadata/nl-NL/keywords.txt
deleted file mode 100644
index 9d6745e6c784..000000000000
--- a/Scripts/fastlane/metadata/nl-NL/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-sociaal,netwerk,notities,jetpack,foto's,schrijven,media,blog,wordpress,website,blogging,ontwerp
diff --git a/Scripts/fastlane/metadata/nl-NL/marketing_url.txt b/Scripts/fastlane/metadata/nl-NL/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/nl-NL/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/nl-NL/subtitle.txt b/Scripts/fastlane/metadata/nl-NL/subtitle.txt
deleted file mode 100644
index eb6c4de86ac3..000000000000
--- a/Scripts/fastlane/metadata/nl-NL/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Beheer je website overal
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/no/description.txt b/Scripts/fastlane/metadata/no/description.txt
deleted file mode 100644
index a542475c5c4b..000000000000
--- a/Scripts/fastlane/metadata/no/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-Administrer eller opprett din WordPress-blogg eller -nettsted rett fra din iOS-enhet: Opprett og rediger innlegg og sider, last opp dine beste bilder og videoer, vis statistikk og svar på kommentarer.
-
-Med WordPress for iOS har du muligheten til å publisere i din hule hånd. Skisser et spontant haiku-dikt fra sofaen. Ta og publiser et bilde i lunsjpausen din. Svar på de nyeste kommentarene, eller sjekk statistikken for å se hvilke nye land dagens besøkende kommer fra.
-
-WordPress for iOS er et åpen kildekode-prosjekt, som betyr at du også kan bidra til utviklingen. Ler mer på https://apps.wordpress.com/contribute/.
-
-WordPress for iOS støtter WordPress.com og selvstendige WordPress.org-installasjoner som kjører versjon 4.0 eller høyere.
-
-Trenger du hjelp med appen? Besøk forumene på https://ios.forums.wordpress.org/ eller send en tweet til @WordPressiOS.
diff --git a/Scripts/fastlane/metadata/no/keywords.txt b/Scripts/fastlane/metadata/no/keywords.txt
deleted file mode 100644
index 216b4985fa33..000000000000
--- a/Scripts/fastlane/metadata/no/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-sosial,notater,foto,skriving,geotagging,medier,blogg,wordpress,nettsted,hjemmeside,blogging,design
diff --git a/Scripts/fastlane/metadata/no/marketing_url.txt b/Scripts/fastlane/metadata/no/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/no/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/no/subtitle.txt b/Scripts/fastlane/metadata/no/subtitle.txt
deleted file mode 100644
index 9114a459ffc9..000000000000
--- a/Scripts/fastlane/metadata/no/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Administrer nettsted overalt
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/pt-BR/description.txt b/Scripts/fastlane/metadata/pt-BR/description.txt
deleted file mode 100644
index 728f01507ca2..000000000000
--- a/Scripts/fastlane/metadata/pt-BR/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-Gerencie ou crie seu blog ou site WordPress diretamente de seu dispositivo iOS: crie e edite posts e páginas, envie suas fotos e vídeos favoritos, veja estatísticas e responda comentários.
-
-Com WordPress para iOS, você tem o poder de publicação em suas mãos! Crie rascunhos em seu sofá. Tire e publique uma foto na hora do almoço. Responda seus últimos comentários e verifique suas estatísticas para saber de qual país seus leitores acessam seu site.
-
-Como o WordPress para iOS é um projeto de código aberto, você também pode contribuir para seu desenvolvimento. Saiba mais em https://apps.wordpress.com/contribute/.
-
-WordPress para iOS suporta sites feitos no WordPress.com e sites WordPress.org rodando a versão 4.0 ou mais recente.
-
-Precisa de ajuda com o aplicativo? Acesse o fórum em https://ios.forums.wordpress.org/ ou tweet para @WordPressiOS.
-
diff --git a/Scripts/fastlane/metadata/pt-BR/keywords.txt b/Scripts/fastlane/metadata/pt-BR/keywords.txt
deleted file mode 100644
index 88a0fe8c53e0..000000000000
--- a/Scripts/fastlane/metadata/pt-BR/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,network,notas,jetpack,fotos,escrita,geotagging,mídia,blog,wordpress,site,blog,design
diff --git a/Scripts/fastlane/metadata/pt-BR/marketing_url.txt b/Scripts/fastlane/metadata/pt-BR/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/pt-BR/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/pt-BR/release_notes.txt b/Scripts/fastlane/metadata/pt-BR/release_notes.txt
deleted file mode 100644
index bf079efb1762..000000000000
--- a/Scripts/fastlane/metadata/pt-BR/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Comentários:
-– Corrigido um erro que fazia com que a seleção de texto ocorresse na linha errada ao se editar comentários.
-– Corrigido um erro que fazia com que o código HTML aparecesse no conteúdo dos comentários.
-– Corrigido um erro que fazia com que os comentários não aparecessem no Leitor.
-
-Publicação:
-– Agora você pode recortar, aumentar/reduzir o zoom e rotacionar as imagens em um post.
-– O aplicativo agora inclui um modo para pré-visualizar no iPhone como ficaria no computador e uma prévia em dispositivos móveis para o iPad. A prévia de posts também inclui uma nova navegação e as opções "Abrir no Safari" e "Compartilhar".
-– Um monte de melhorias no Editor de Blocos: adicionado um ícone de toque-longo para inserir blocos antes/depois e um botão "Editar" sobreposto a blocos de imagens que estiverem selecionados. O editor irá tentar recarregar as imagens se houver problemas de conexão. E o bloco Galeria agora oferece opções de tamanho de imagens.
-– Corrigimos um erro que poderia desativar os comentários enquanto se estivesse visualizando um rascunho.
-
-Registro e acesso:
-- O registro e acesso via link mágico agora suporta múltiplos clientes de email. Tocando-se no botão "Abrir Email" irá se apresentar uma lista de aplicativos instalados para o usuário escolher.
-
-Leitor:
-- Os aplicativos agora suportam republicação de posts! Toque no novo botão "Reblog" na barra de funções do post para escolher em qual dos seus sites você quer publicar e abrir o editor de sua preferência com um conteúdo copiado do post original.
diff --git a/Scripts/fastlane/metadata/pt-BR/subtitle.txt b/Scripts/fastlane/metadata/pt-BR/subtitle.txt
deleted file mode 100644
index 7b9570322d39..000000000000
--- a/Scripts/fastlane/metadata/pt-BR/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Publique em qualquer lugar
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/ru/description.txt b/Scripts/fastlane/metadata/ru/description.txt
deleted file mode 100644
index ea495a446e31..000000000000
--- a/Scripts/fastlane/metadata/ru/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-Управляйте или создавайте ваш блог или сайт прямо с вашего iOS устройства: просматривайте статистику, управляйте комментариями, создавайте и редактируйте страницы и записи, загружайте медиафайлы.
-
-С WordPress для iOS у вас есть сила публикации на вашей ладони. Набросайте неожиданно сложившееся в голове хайку, лежа на диване, сделайте фото на обеденном перерыве для еженедельного фотоконкурса в газете. Ответьте на последние комментарии или посмотрите статистику, чтобы увидеть, откуда сегодня приходили читатели.
-
-WordPress для iOS - проект с открытым исходным кодом, что означает, что вы также можете принять участие в его разработке, узнайте больше на https://apps.wordpress.com/contribute/.
-
-WordPress для iOS поддерживает как WordPress.com так и свои сайты с WordPress 4.0 или новее.
-
-Нужна помощь с приложением? Посетите форум - https://ios.forums.wordpress.org/ или напишите в Twitter на @WordPressiOS.
diff --git a/Scripts/fastlane/metadata/ru/keywords.txt b/Scripts/fastlane/metadata/ru/keywords.txt
deleted file mode 100644
index ab6cbfc0f9a1..000000000000
--- a/Scripts/fastlane/metadata/ru/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design
diff --git a/Scripts/fastlane/metadata/ru/marketing_url.txt b/Scripts/fastlane/metadata/ru/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/ru/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/ru/release_notes.txt b/Scripts/fastlane/metadata/ru/release_notes.txt
deleted file mode 100644
index 60c76a50865a..000000000000
--- a/Scripts/fastlane/metadata/ru/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Комментирование:
-- Исправлена ошибка с выделением неверной строки при редактировании комментариев.
-- Исправлена ошибка с появлением разметки HTML в содержимом комментариев.
-- Исправлена проблема с непоявлением комментариев в Чтиве.
-
-Публикация:
-- Вы можете обрезать, уменьшать или увеличивать, а также вращать изображения в записи.
-- Приложение теперь включает предпросмотр как на ПК с IPhone, и мобильный предпросмотр на iPad. В режиме предпросмотра также можно использовать возможности поделиться и "открыть в Safari", а также новую навигацию.
-- Уйма улучшений в редакторе блоков: при длительном нажатии - значок добавления блоков перед и после, плавающая кнопка редактирования на выбранных блоках изображений. Редактор блоков будет повторно пытаться загружать изображения после сбоев подключений к сети. Блок галереи поддерживает выбор вариантов размера.
-- Исправлена ошибка с отключением комментариев в черновиках записей при их предпросмотре.
-
-Регистрация и вход:
-- Магические ссылки теперь поддерживаются множеством почтовых программ. Нажатие на "открыть email" позволит вам выбрать из установленных почтовых приложений.
-
-Чтение:
-- Поддержка реблоггинга! Нажмите на кнопку "реблог" на панели действий с записью, выберите на каком из сайтов вы хотите перепубликовать ее и запись откроется в редакторе на выбранном сайте, с подготовленным содержимым оригинальной записи.
diff --git a/Scripts/fastlane/metadata/ru/subtitle.txt b/Scripts/fastlane/metadata/ru/subtitle.txt
deleted file mode 100644
index 6d96c7ba8279..000000000000
--- a/Scripts/fastlane/metadata/ru/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Управляйте сайтом отовсюду
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/sv/description.txt b/Scripts/fastlane/metadata/sv/description.txt
deleted file mode 100644
index d634445631f7..000000000000
--- a/Scripts/fastlane/metadata/sv/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-Hantera eller skapa din WordPress-blogg eller -webbplats direkt från din iOS-enhet: skapa och redigera inlägg och sidor, ladda upp dina bästa bilder och videoklipp, kolla statistiken och svara på kommentarer.
-
-Med WordPress för iOS håller du möjligheten att publicera i din hand. Skriv ett utkast till en haiku när du sitter i soffan. Ta ett foto och publicera på lunchrasten. Svara på dina senaste kommentarer eller kolla statistiken för att se från vilka nya länder du fått besökare idag.
-
-WordPress för iOS är ett öppen källkodsprojekt, vilket innebär att även du är välkommen att bidra till dess utveckling. Läs mer på https://apps.wordpress.com/contribute/.
-
-WordPress för iOS stöder WordPress.com och webbplatser på egen server med programvaran WordPress.org version 4.0 eller senare.
-
-Behöver du hjälp med appen? Välkommen till forumen på https://ios.forums.wordpress.org/ eller twittra till oss på @WordPressiOS.
diff --git a/Scripts/fastlane/metadata/sv/keywords.txt b/Scripts/fastlane/metadata/sv/keywords.txt
deleted file mode 100644
index 45ca6d0a1408..000000000000
--- a/Scripts/fastlane/metadata/sv/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-socialt,nätverk,anteckningar,foton,skriva,media,blogg,wordpress,webbplats,bloggning,blogga,hemsida
diff --git a/Scripts/fastlane/metadata/sv/marketing_url.txt b/Scripts/fastlane/metadata/sv/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/sv/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/sv/release_notes.txt b/Scripts/fastlane/metadata/sv/release_notes.txt
deleted file mode 100644
index 137a45df038a..000000000000
--- a/Scripts/fastlane/metadata/sv/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Kommentarer:
-- Rättat ett fel där markeringen av text hamnade på fel rad när man redigerade kommentarer.
-- Rättat ett fel som kunde göra att HTML-kod visades i kommentarers innehåll.
-- Rättat ett fel som gjorde att kommentarer inte visades i läsaren.
-
-Publicering:
-- Nu kan du beskära, förstora/förminska och rotera bilder i ett inlägg.
-- Nu innehåller appen ett förhandsgranskningsläge för datorskärm på iPhone och förhandsgranskning för mobil på iPad. Förhandsvisning av inlägg har nu ny en ny menypost: ”Öppna länk i Safari” och nya delningsalternativ.
-- En rad förbättringar av blockredigeraren: Lagt till en ikon för långt tryck för infogning av block före/efter och ett överlägg ”Redigera” på vissa bildblock. Redigeraren kommer att försöka ladda bilder på nytt efter eventuella problem med internetförbindelseen. Och blocket ”Galleri” har nu stöd för olika bildstorlekar.
-- Vi har rättat ett fel som kunde inaktivera kommentarer på ett utkast vid förhansgranskning.
-
-Registrering och inloggning
-- Nu stöder registrering och inloggning med hjälp av ”magiska länkar” flera olika e-postklienter. När man trycker på knappen ”öppna e-post” visas en lista där man kan välja mellan de e-postprogra som finns installerade.
-
-Läsaren
-- Nu har appen stöd för reblogging av inlägg! Tryck på den nya knappen ”reblogga” i verktygsfältet för åtgärder på inlägg och välj till vilka av dina webbplatser du vill publicera och öppna din favoritredigerare med innehållet från det ursprungliga inlägget redan inlagt.
diff --git a/Scripts/fastlane/metadata/sv/subtitle.txt b/Scripts/fastlane/metadata/sv/subtitle.txt
deleted file mode 100644
index 56091961f560..000000000000
--- a/Scripts/fastlane/metadata/sv/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Publicera när det passar dig
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/tr/description.txt b/Scripts/fastlane/metadata/tr/description.txt
deleted file mode 100644
index 6fed4571c5bf..000000000000
--- a/Scripts/fastlane/metadata/tr/description.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-WordPress blogunuzu veya web sitenizi doğrudan iOS cihazınızdan yönetin veya oluşturun: yazılar ve sayfalar oluşturun ve düzenleyin, beğendiğiniz fotoğraflarınızı ve videolarınızı yükleyin, istatistikleri görüntüleyin ve yorumlara cevap verin.
-
-iOS için WordPress ile yayımcılığın gücünü avucunuzda tutarsınız. Kanepede otururken hemen o anda bir haiku yazın. Öğle tatilinde fotoğraf çekin ve yayımlayın. En son yorumlarınıza yanıt verin veya bugünün okuyucularının hangi yeni ülkelerden geldiğini görmek için istatistiklerinizi kontrol edin.
-
-iOS için WordPress bir açık kaynak projesidir, bu da sizin gelişimine katkıda bulunabileceğiniz anlamına gelir. https://apps.wordpress.com/contribute/ adresinden daha fazla bilgi edinin.
-
-iOS için WordPress, WordPress.com ve kendi kendine barındırılan WordPress 4.0 veya daha yüksek bir sürümünü çalıştıran WordPress.org sitelerini destekler.
-
-Uygulamayla ilgili yardım mı gerekli? https://ios.forums.wordpress.org/ adresinde forumları ziyaret edin veya @WordPressiOS adresinden bize tweet atın.
diff --git a/Scripts/fastlane/metadata/tr/keywords.txt b/Scripts/fastlane/metadata/tr/keywords.txt
deleted file mode 100644
index 56b8cfc0c476..000000000000
--- a/Scripts/fastlane/metadata/tr/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-blog,wordpress,jetpack,websitesi,sosyal,ağ,not,fotoraf,yazma,ortam,bloglama,tasarım,dizayn
diff --git a/Scripts/fastlane/metadata/tr/marketing_url.txt b/Scripts/fastlane/metadata/tr/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/tr/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/tr/release_notes.txt b/Scripts/fastlane/metadata/tr/release_notes.txt
deleted file mode 100644
index a732bd98c0af..000000000000
--- a/Scripts/fastlane/metadata/tr/release_notes.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-Yorum yapma:
-- Yorumları düzenlerken metin seçiminin yanlış satırda olmasına neden olan bir hata düzeltildi.
-- HTML işaretlemesinin yorum içeriğinde görüntülenmesine neden olan bir hata düzeltildi.
-- Yorumların okuyucuda görünmemesine neden olan bir sorun düzeltildi.
-
-Yayıncılık:
-- Artık bir yazıdaki görüntüleri kırpabilir, yakınlaştırabilir / uzaklaştırabilir ve döndürebilirsiniz.
-- Uygulama artık iPhone'da bir masaüstü ön izleme modu ve iPad'de mobil ön izleme içeriyor. Yayın ön izlemesinde ayrıca "Safari'de aç" ve "Paylaş seçenekleri" gibi yeni gezinme seçenekleri de vardır.
-- Çok sayıda blok düzenleyici geliştirmesi: Blokları önce / sonra eklemek için uzun basma simgesi ve seçilen görüntü bloklarına “Düzenle” düğmesi kaplaması eklendi. Düzenleyici, bağlantı sorunlarından sonra görüntüleri yüklemeyi yeniden deneyecek. Galeri bloğu artık görüntü boyutu seçeneklerini destekliyor.
-- Bir taslak gönderideki yorumları önizlerken devre dışı bırakabilecek bir hatayı düzelttik.
-
-Kayıt ve Giriş
-- Sihirli bağlantı yoluyla kaydolma veya giriş artık birden çok e-posta istemcisini destekliyor. “E-postayı aç” düğmesine dokunduğunuzda, seçebileceğiniz yüklü e-posta istemcilerinin bir listesi gösterilir.
-
-Okuyucu
-- Uygulamalar artık yeniden blog yazmayı destekliyor! Sitelerinizden hangilerine yayın yapılacağını seçmek ve seçtiğiniz editörü orijinal yayından önceden doldurulmuş içerikle açmak için yayınlama işlem çubuğundaki yeni "yeniden blog" düğmesine dokunun.
diff --git a/Scripts/fastlane/metadata/tr/subtitle.txt b/Scripts/fastlane/metadata/tr/subtitle.txt
deleted file mode 100644
index 196967d7ad13..000000000000
--- a/Scripts/fastlane/metadata/tr/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-Dilediğiniz yerde yayımlayın
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/zh-Hans/description.txt b/Scripts/fastlane/metadata/zh-Hans/description.txt
deleted file mode 100644
index 6238b94f19ef..000000000000
--- a/Scripts/fastlane/metadata/zh-Hans/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-您可以直接在 iOS 设备上管理或创建自己的 WordPress 博客或网站:创建和编辑文章和页面,上传您最喜欢的照片和视频,查看统计数据和回复评论。
-
-借助 iOS 版 WordPress,您可以随时随地发布信息,尽在您的掌握。在沙发上为即兴创作的俳句撰写草稿。在午休时间拍照并发表。回复最新评论,或者查看统计数据,看看今天的访客来自哪些新国家/地区。
-
-iOS 版 WordPress 是开源项目,这意味着您也可以为它的开发贡献一份力量。详细信息请访问 https://apps.wordpress.com/contribute/。
-
-iOS 版 WordPress 支持 WordPress.com 和运行 WordPress 4.0 或更高版本的自托管 WordPress.org 站点。
-
-需要应用方面的帮助?请访问我们的论坛 (https://ios.forums.wordpress.org/),或在 Twitter 上 @ 我们 (@WordPressiOS)。
-
diff --git a/Scripts/fastlane/metadata/zh-Hans/keywords.txt b/Scripts/fastlane/metadata/zh-Hans/keywords.txt
deleted file mode 100644
index 4bf3ed1a28a4..000000000000
--- a/Scripts/fastlane/metadata/zh-Hans/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-社交, 网络, 备注, Jetpack, 照片, 写作, 地理标记, 媒体, 博客, WordPress, 网站, 撰写博客, 设计
diff --git a/Scripts/fastlane/metadata/zh-Hans/marketing_url.txt b/Scripts/fastlane/metadata/zh-Hans/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/zh-Hans/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/zh-Hans/subtitle.txt b/Scripts/fastlane/metadata/zh-Hans/subtitle.txt
deleted file mode 100644
index 23518f1bad87..000000000000
--- a/Scripts/fastlane/metadata/zh-Hans/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-随时随地管理您的网站
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/zh-Hant/description.txt b/Scripts/fastlane/metadata/zh-Hant/description.txt
deleted file mode 100644
index 1092b1a8e0be..000000000000
--- a/Scripts/fastlane/metadata/zh-Hant/description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-使用 iOS 裝置管理或建立 WordPress 網誌或網站:建立及編輯文章和網頁、上傳最愛的相片和影片、查看統計資料及回覆留言。
-
-使用 iOS 版 WordPress,你就能夠在掌上輕鬆發佈文章。窩在沙發上信手創作幾行俳句;趁著午休時間拍照並上傳;回覆最新的回應;或者查看統計資料,並瞭解今天的讀者來自哪些國家。
-
-Android 版 WordPress 是開放原始碼專案,這表示你也可以貢獻己力協助開發此程式。深入瞭解:https://apps.wordpress.com/contribute/。
-
-iOS 版 WordPress 支援 WordPress.com 和執行 WordPress 4.0 或以上版本的自助託管 WordPress.org 網站。
-
-需要應用程式方面的協助嗎?請造訪論壇:https://ios.forums.wordpress.org/ 或使用 Twitter 推文給我們:@WordPressiOS。
-
diff --git a/Scripts/fastlane/metadata/zh-Hant/keywords.txt b/Scripts/fastlane/metadata/zh-Hant/keywords.txt
deleted file mode 100644
index 36944dd212c0..000000000000
--- a/Scripts/fastlane/metadata/zh-Hant/keywords.txt
+++ /dev/null
@@ -1 +0,0 @@
-社交, 網路, 備註, jetpack, 相片, 寫作, 地理標籤, 媒體, 網誌, wordpress, 網站, 撰寫網誌, 設計
diff --git a/Scripts/fastlane/metadata/zh-Hant/marketing_url.txt b/Scripts/fastlane/metadata/zh-Hant/marketing_url.txt
deleted file mode 100644
index 0ad53b917f84..000000000000
--- a/Scripts/fastlane/metadata/zh-Hant/marketing_url.txt
+++ /dev/null
@@ -1 +0,0 @@
-https://apps.wordpress.com/mobile/
\ No newline at end of file
diff --git a/Scripts/fastlane/metadata/zh-Hant/subtitle.txt b/Scripts/fastlane/metadata/zh-Hant/subtitle.txt
deleted file mode 100644
index ffbac7d88c64..000000000000
--- a/Scripts/fastlane/metadata/zh-Hant/subtitle.txt
+++ /dev/null
@@ -1 +0,0 @@
-隨時隨地管理網站
\ No newline at end of file
diff --git a/Scripts/fastlane/screenshots.json b/Scripts/fastlane/screenshots.json
deleted file mode 100644
index b39d6537324b..000000000000
--- a/Scripts/fastlane/screenshots.json
+++ /dev/null
@@ -1,336 +0,0 @@
-{
- "version": 0.1,
- "shadow_offset": 40,
- "background_color": "#016087",
- "stylesheet": "appstoreres/assets/style.css",
- "devices": [
- {
- "name": "iPhone Xs max",
- "canvas_size": [1242,2688],
- "text_size": [1242, 510],
- "font_size": "90px",
- "screenshot_size": [921, 1994],
- "screenshot_offset": [161, 559],
- "screenshot_mask": "appstoreres/assets/iphone-x-mask.png",
- "device_frame": "appstoreres/assets/iphone-x.png",
- "device_frame_size": [1200, 2268],
- "device_frame_offset": [21, 420]
- },
- {
- "name": "iPhone 8",
- "canvas_size": [1242,2208],
- "text_size": [1242, 320],
- "font_size": "70px",
- "screenshot_size": [921, 1638],
- "screenshot_offset": [161, 580],
- "device_frame": "appstoreres/assets/iphone-8-white.png",
- "device_frame_size": [1129, 2211],
- "device_frame_offset": [56, 292]
- },
- {
- "name": "iPad Pro",
- "canvas_size": [2732,2048],
- "text_size": [2732, 317],
- "font_size": "90px",
- "screenshot_size": [2220, 1670],
- "screenshot_offset": [257, 410],
- "device_frame": "appstoreres/assets/ipad-pro.png",
- "device_frame_size": [2788, 1987],
- "device_frame_offset": [-25, 248]
- },
- {
- "name": "iPad X",
- "canvas_size": [2732,2048],
- "text_size": [2732, 317],
- "font_size": "90px",
- "screenshot_size": [2220, 1670],
- "screenshot_offset": [257, 410],
- "screenshot_mask": "appstoreres/assets/ipad-x-mask.png",
- "device_frame": "appstoreres/assets/ipad-x.png",
- "device_frame_size": [2612, 2022],
- "device_frame_offset": [61, 231]
- }
- ],
- "entries": [
- {
- "device": "iPhone Xs max",
- "text": "appstoreres/metadata/%s/app_store_screenshot_1.html",
- "screenshot": "iPhone XS Max-1-PostEditor.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/white-box.svg",
- "size": [865,1165],
- "position": [186, 1101]
- },
- {
- "file": "appstoreres/assets/attachments/1-photos-iPhoneX.png",
- "size": [1108, 1364],
- "position": [71, 1085]
- }
- ]
- },
- {
- "device": "iPhone Xs max",
- "text": "appstoreres/metadata/%s/app_store_screenshot_2.html",
- "screenshot": "iPhone XS Max-2-Stats.png",
- "background": "#9658A3",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/2-chart.png",
- "size": [1060,672],
- "position": [91, 941]
- }
- ]
- },
- {
- "device": "iPhone Xs max",
- "text": "appstoreres/metadata/%s/app_store_screenshot_3.html",
- "screenshot": "iPhone XS Max-3-Notifications.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/3-notifications-iPhoneX.png",
- "size": [1156, 1340],
- "position": [89, 906]
- }
- ]
- },
- {
- "device": "iPhone Xs max",
- "text": "appstoreres/metadata/%s/app_store_screenshot_4.html",
- "screenshot": "iPhone XS Max-4-Media.png",
- "background": "#9658A3",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/4-photos-iPhoneX.png",
- "size": [1072,1541],
- "position": [71, 872]
- }
- ]
- },
- {
- "device": "iPhone Xs max",
- "text": "appstoreres/metadata/%s/app_store_screenshot_5.html",
- "screenshot": "iPhone XS Max-5-DraftEditor.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/ios-dictation-keyboard-x.png",
- "size": [921,933],
- "position": [161, 1622]
- },
- {
- "file": "appstoreres/assets/attachments/5-1.png",
- "size": [1058,288],
- "position": [92, 1988]
- }
- ]
- },
- {
- "device": "iPhone 8",
- "text": "appstoreres/metadata/%s/app_store_screenshot_1.html",
- "screenshot": "iPhone 8 Plus-1-PostEditor.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/white-box.svg",
- "size": [878,1062],
- "position": [181, 1058]
- },
- {
- "file": "appstoreres/assets/attachments/1-photos-iPhoneX.png",
- "size": [1108,1364],
- "position": [71, 1090]
- }
- ]
- },
- {
- "device": "iPhone 8",
- "text": "appstoreres/metadata/%s/app_store_screenshot_2.html",
- "screenshot": "iPhone 8 Plus-2-Stats.png",
- "background": "#9658A3",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/2-chart.png",
- "size": [1060,672],
- "position": [91, 933]
- }
- ]
- },
- {
- "device": "iPhone 8",
- "text": "appstoreres/metadata/%s/app_store_screenshot_3.html",
- "screenshot": "iPhone 8 Plus-3-Notifications.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/3-notifications-iPhone8.png",
- "size": [1156, 1340],
- "position": [55, 705]
- }
- ]
- },
- {
- "device": "iPhone 8",
- "text": "appstoreres/metadata/%s/app_store_screenshot_4.html",
- "screenshot": "iPhone 8 Plus-4-Media.png",
- "background": "#9658A3",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/4-photos-iPhone8.png",
- "size": [1115,1459],
- "position": [55, 705]
- }
- ]
- },
- {
- "device": "iPhone 8",
- "text": "appstoreres/metadata/%s/app_store_screenshot_5.html",
- "screenshot": "iPhone 8 Plus-5-DraftEditor.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/ios-dictation-keyboard.png",
- "size": [921,893],
- "position": [161, 1407]
- },
- {
- "file": "appstoreres/assets/attachments/5-1.png",
- "size": [1058,288],
- "position": [92, 1695]
- }
- ]
- },
- {
- "device": "iPad X",
- "text": "appstoreres/metadata/%s/app_store_screenshot_1.html",
- "screenshot": "iPad Pro (12.9-inch) (3rd generation)-1-PostEditor.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/white-box.svg",
- "size": [995,1081],
- "position": [893, 772]
- },
- {
- "file": "appstoreres/assets/attachments/1-photos-iPad.png",
- "size": [1213,1428],
- "position": [751, 819]
- }
- ]
- },
- {
- "device": "iPad X",
- "text": "appstoreres/metadata/%s/app_store_screenshot_4.html",
- "screenshot": "iPad Pro (12.9-inch) (3rd generation)-4-Media.png",
- "background": "#9658A3",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/4-photos-iPad.png",
- "size": [1795,1368],
- "position": [736, 507]
- }
- ]
- },
- {
- "device": "iPad X",
- "text": "appstoreres/metadata/%s/app_store_screenshot_2.html",
- "screenshot": "iPad Pro (12.9-inch) (3rd generation)-2-Stats.png",
- "background": "#9658A3",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/2-chart-iPad.png",
- "size": [1745,720],
- "position": [746,565]
- }
- ]
- },
- {
- "device": "iPad X",
- "text": "appstoreres/metadata/%s/app_store_screenshot_3.html",
- "screenshot": "iPad Pro (12.9-inch) (3rd generation)-3-Notifications.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/3-notifications-iPad.png",
- "size": [880, 1158],
- "position": [117, 688]
- }
- ]
- },
- {
- "device": "iPad X",
- "text": "appstoreres/metadata/%s/app_store_screenshot_6.html",
- "screenshot": "iPad Pro (12.9-inch) (3rd generation)-6-No-Keyboard-Editor.png",
- "background": "#0F78A2",
- "attachments": [
- ]
- },
- {
- "device": "iPad Pro",
- "text": "appstoreres/metadata/%s/app_store_screenshot_1.html",
- "screenshot": "iPad Pro (12.9-inch) (2nd generation)-1-PostEditor.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/white-box.svg",
- "size": [995,1081],
- "position": [893, 772]
- },
- {
- "file": "appstoreres/assets/attachments/1-photos-iPad.png",
- "size": [1213,1428],
- "position": [751, 819]
- }
- ]
- },
- {
- "device": "iPad Pro",
- "text": "appstoreres/metadata/%s/app_store_screenshot_2.html",
- "screenshot": "iPad Pro (12.9-inch) (2nd generation)-2-Stats.png",
- "background": "#9658A3",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/2-chart-iPad.png",
- "size": [1745,720],
- "position": [746,565]
- }
- ]
- },
- {
- "device": "iPad Pro",
- "text": "appstoreres/metadata/%s/app_store_screenshot_3.html",
- "screenshot": "iPad Pro (12.9-inch) (2nd generation)-3-Notifications.png",
- "background": "#0F78A2",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/3-notifications-iPad.png",
- "size": [924, 1222],
- "position": [117, 688]
- }
- ]
- },
- {
- "device": "iPad Pro",
- "text": "appstoreres/metadata/%s/app_store_screenshot_4.html",
- "screenshot": "iPad Pro (12.9-inch) (2nd generation)-4-Media.png",
- "background": "#9658A3",
- "attachments": [
- {
- "file": "appstoreres/assets/attachments/4-photos-iPad.png",
- "size": [1795,1368],
- "position": [736, 507]
- }
- ]
- },
- {
- "device": "iPad Pro",
- "text": "appstoreres/metadata/%s/app_store_screenshot_6.html",
- "screenshot": "iPad Pro (12.9-inch) (2nd generation)-6-No-Keyboard-Editor.png",
- "background": "#0F78A2",
- "attachments": [
- ]
- }
- ]
-}
\ No newline at end of file
diff --git a/Scripts/fix-screenshots.rb b/Scripts/fix-screenshots.rb
index 88562299484a..8ae17eeb0b5f 100755
--- a/Scripts/fix-screenshots.rb
+++ b/Scripts/fix-screenshots.rb
@@ -1,15 +1,16 @@
#!/usr/bin/env ruby
+# frozen_string_literal: true
require 'find'
-Find.find('./fastlane/screenshots') { |e|
- next if File.directory?(e)
- next if File.extname(e) != ".png"
+Find.find('./fastlane/screenshots') do |e|
+ next if File.directory?(e)
+ next if File.extname(e) != '.png'
- info = `identify "#{e}"`.sub(e, '').split
- dimensions = info[1].split('x')
+ info = `identify "#{e}"`.sub(e, '').split
+ dimensions = info[1].split('x')
- if e.include?('iPad') && dimensions[0] < dimensions[1]
- `convert "#{e}" -rotate -90 "#{e}"`
- puts "✅ #{e}"
- end
-}
+ if e.include?('iPad') && dimensions[0] < dimensions[1]
+ `convert "#{e}" -rotate -90 "#{e}"`
+ puts "✅ #{e}"
+ end
+end
diff --git a/Scripts/fix-translation b/Scripts/fix-translation
deleted file mode 100755
index 4c0f3afa7fae..000000000000
--- a/Scripts/fix-translation
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env swift
-
-import Foundation
-
-guard CommandLine.arguments.count > 1 else {
- print("Usage: fix-translation path/to/Localizable.strings")
- exit(1)
-}
-
-func fix(file: String) throws {
- var encoding = String.Encoding.utf16LittleEndian
- let contents = try String(contentsOfFile: file, usedEncoding: &encoding)
- let regexp = try NSRegularExpression(pattern: "^\"(.*)\" = \"\";$", options: [])
- var output = ""
- contents.enumerateLines { line, _ in
- let replaced = regexp.stringByReplacingMatches(in: line, options: [], range: NSRange(location: 0, length: line.count), withTemplate: "\"$1\" = \"$1\";")
- output.append(replaced as String)
- output.append("\n")
- }
- try output.write(toFile: file, atomically: true, encoding: encoding)
-}
-
-do {
- try CommandLine.arguments.dropFirst().forEach { file in
- try fix(file: file)
- }
-} catch {
- print(error)
-}
diff --git a/Scripts/install-oclint.sh b/Scripts/install-oclint.sh
deleted file mode 100755
index 34255590e0f7..000000000000
--- a/Scripts/install-oclint.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-echo "[*] installing oclint 0.8.1"
-pushd .
-cd $TMPDIR
-curl http://archives.oclint.org/releases/0.8/oclint-0.8.1-x86_64-darwin-14.0.0.tar.gz > oclint.tar.gz
-tar -zxvf oclint.tar.gz
-cp oclint-0.8.1/bin/oclint* /usr/local/bin/
-cp -rp oclint-0.8.1/lib/* /usr/local/lib/
-popd
diff --git a/Scripts/localize.py b/Scripts/localize.py
deleted file mode 100755
index 6b2a56bbe731..000000000000
--- a/Scripts/localize.py
+++ /dev/null
@@ -1,158 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# This program is free software. It comes without any warranty, to
-# the extent permitted by applicable law. You can redistribute it
-# and/or modify it under the terms of the Do What The Fuck You Want
-# To Public License, Version 2, as published by Sam Hocevar. See
-# http://sam.zoy.org/wtfpl/COPYING for more details.
-#
-# Localize.py - Incremental localization on XCode projects
-# João Moreno 2009
-# http://joaomoreno.com/
-
-from sys import argv
-from codecs import open
-from re import compile
-from copy import copy
-import os
-
-re_translation = compile(r'^"(.+)" = "(.+)";$')
-re_comment_single = compile(r'^/(/.*|\*.*\*/)$')
-re_comment_start = compile(r'^/\*.*$')
-re_comment_end = compile(r'^.*\*/$')
-
-def print_help():
- print u"""Usage: merge.py merged_file old_file new_file
-Xcode localizable strings merger script. João Moreno 2009."""
-
-class LocalizedString():
- def __init__(self, comments, translation):
- self.comments, self.translation = comments, translation
- self.key, self.value = re_translation.match(self.translation).groups()
-
- def __unicode__(self):
- return u'%s%s\n' % (u''.join(self.comments), self.translation)
-
-class LocalizedFile():
- def __init__(self, fname=None, auto_read=False):
- self.fname = fname
- self.strings = []
- self.strings_d = {}
-
- if auto_read:
- self.read_from_file(fname)
-
- def read_from_file(self, fname=None):
- fname = self.fname if fname == None else fname
- try:
- f = open(fname, encoding='utf_16', mode='r')
- except:
- print 'File %s does not exist.' % fname
- exit(-1)
-
- line = f.readline()
- while line and line == u'\n':
- line = f.readline()
-
- while line:
- comments = [line]
-
- if not re_comment_single.match(line):
- while line and not re_comment_end.match(line):
- line = f.readline()
- comments.append(line)
-
- line = f.readline()
- if line and re_translation.match(line):
- translation = line
- else:
- raise Exception('invalid file: %s' % line)
-
- line = f.readline()
- while line and line == u'\n':
- line = f.readline()
-
- string = LocalizedString(comments, translation)
- self.strings.append(string)
- self.strings_d[string.key] = string
-
- f.close()
-
- def save_to_file(self, fname=None):
- fname = self.fname if fname == None else fname
- try:
- f = open(fname, encoding='utf_16', mode='w')
- except:
- print 'Couldn\'t open file %s.' % fname
- exit(-1)
-
- for string in self.strings:
- f.write(string.__unicode__())
-
- f.close()
-
- def merge_with(self, new):
- merged = LocalizedFile()
-
- for string in new.strings:
- if self.strings_d.has_key(string.key):
- new_string = copy(self.strings_d[string.key])
- new_string.comments = string.comments
- string = new_string
-
- merged.strings.append(string)
- merged.strings_d[string.key] = string
-
- return merged
-
-def merge(merged_fname, old_fname, new_fname):
- try:
- old = LocalizedFile(old_fname, auto_read=True)
- new = LocalizedFile(new_fname, auto_read=True)
- except Exception as e:
- print 'Error: input files have invalid format. old: %s, new: %s' % (old_fname, new_fname)
- print e
-
- merged = old.merge_with(new)
-
- merged.save_to_file(merged_fname)
-
-STRINGS_FILE = 'Localizable.strings'
-
-def localize(path, language, include_pods_and_frameworks):
- if "Scripts" in path:
- print "Must run script from the root folder"
- quit()
-
- os.chdir(path)
- language = os.path.join(path, language)
-
- original = merged = language + os.path.sep + STRINGS_FILE
- old = original + '.old'
- new = original + '.new'
-
- # TODO: This is super ugly, we have to come up with a better way of doing it
- if include_pods_and_frameworks:
- find_cmd = 'find . ../Pods/WordPress* ../Pods/WPMediaPicker ../WordPressShared/WordPressShared ../Pods/Gutenberg -name "*.m" -o -name "*.swift" | grep -v Vendor | grep -v ./WordPressTest/I18n.swift'
- else:
- find_cmd = 'find . -name "*.m" -o -name "*.swift" | grep -v Vendor | grep -v ./WordPressTest/I18n.swift'
- filelist = os.popen(find_cmd).read().strip().split('\n')
- filelist = '"{0}"'.format('" "'.join(filelist))
-
- if os.path.isfile(original):
- os.rename(original, old)
- os.system('genstrings -q -o "%s" %s' % (language, filelist))
- os.rename(original, new)
- merge(merged, old, new)
- os.remove(new)
- os.remove(old)
- else:
- os.system('genstrings -q -o "%s" %s' % (language, filelist))
-
-if __name__ == '__main__':
- basedir = os.getcwd()
- localize(os.path.join(basedir, 'WordPress'), 'Resources/en.lproj', True)
- localize(os.path.join(basedir, 'WordPress', 'WordPressTodayWidget'), 'Base.lproj', False)
- localize(os.path.join(basedir, 'WordPress', 'WordPressShareExtension'), 'Base.lproj', False)
-
diff --git a/Scripts/manage-version.sh b/Scripts/manage-version.sh
deleted file mode 100755
index 351ca0b69a52..000000000000
--- a/Scripts/manage-version.sh
+++ /dev/null
@@ -1,459 +0,0 @@
-#!/bin/sh
-
-### Misc definitions
-CMD_CREATE="create-branch"
-CMD_CREATE_SHORT="create"
-CMD_UPDATE="update-branch"
-CMD_UPDATE_SHORT="update"
-CMD_FORCE="force-branch"
-CMD_FORCE_SHORT="force"
-CMD_BUMP_RELEASE="bump-release"
-CMD_BUMP_HOTFIX="bump-hotfix"
-CMD_BUMP_INTERNAL="bump-internal"
-CMD_GET_VERSION="get-version"
-
-# Regex for "is a number"
-IS_A_NUM_RE="^[0-9]+$"
-
-# Color/formatting support
-OUTPUT_NORM="\033[0m"
-OUTPUT_RED="\033[31m"
-OUTPUT_GREEN="\033[32m"
-OUTPUT_BOLD="\033[1m"
-
-# Config files
-publicConfig=("Version.public.xcconfig")
-internalConfig=("Version.internal.xcconfig")
-
-
-### Function definitions
-# Show script usage, commands and options
-function showUsage() {
- # Help message
- echo "Usage: $exeName command new-version [new-internal-version]"
- echo ""
- echo " Available commands:"
- echo " $CMD_GET_VERSION: reads the current version"
- echo " $CMD_BUMP_RELEASE: reads the current version, bumps the release digits and creates the new branch (works only on develop branch)"
- echo " $CMD_BUMP_HOTFIX: reads the current version, bumps the hotfix digit and updates the IDs (works only on release branch)"
- echo " $CMD_BUMP_INTERNAL: reads the current version, bumps the internal build digit and updates the IDs (works only on release branch)"
- echo " $CMD_CREATE (or $CMD_CREATE_SHORT): creates the new branch and updates the version IDs"
- echo " $CMD_UPDATE (or $CMD_UPDATE_SHORT): updates the version IDs"
- echo " $CMD_FORCE (or $CMD_FORCE_SHORT): force the update to the provided version, skipping the checks."
- echo ""
- echo "Example: $exeName $CMD_GET_VERSION"
- echo "Example: $exeName $CMD_BUMP_RELEASE"
- echo "Example: $exeName $CMD_BUMP_HOTFIX"
- echo "Example: $exeName $CMD_BUMP_INTERNAL"
- echo "Example: $exeName $CMD_CREATE_SHORT 9.3.0"
- echo "Example: $exeName $CMD_UPDATE_SHORT 9.3.0.1"
- echo "Example: $exeName $CMD_UPDATE_SHORT 9.3.0.1 9.3.0.20180129"
- echo ""
- exit 1
-}
-
-function showErrorMessage() {
- message=$1
- echo "$OUTPUT_RED$message$OUTPUT_NORM"
- echo $message >> $logFile
-}
-
-function showOkMessage() {
- message=$1
- echo "$OUTPUT_GREEN$message$OUTPUT_NORM"
- echo $message >> $logFile
-}
-
-function showTitleMessage() {
- message=$1
- echo "$OUTPUT_BOLD$message$OUTPUT_NORM"
- echo $message >> $logFile
-}
-
-function showMessage() {
- echo "$1" | tee -a $logFile
-}
-
-# Verifies the command against the known ones and normalize to the extended version
-# Shows script usage in case of unknown command
-function verifyCommand() {
- if [ $cmd == $CMD_CREATE ] || [ $cmd == $CMD_CREATE_SHORT ]; then
- cmd=$CMD_CREATE
- return
- fi
-
- if [ $cmd == $CMD_UPDATE ] || [ $cmd == $CMD_UPDATE_SHORT ]; then
- cmd=$CMD_UPDATE
- return
- fi
-
- if [ $cmd == $CMD_FORCE ] || [ $cmd == $CMD_FORCE_SHORT ]; then
- cmd=$CMD_FORCE
- return
- fi
-
- if [ $cmd == $CMD_BUMP_RELEASE ] || [ $cmd == $CMD_BUMP_INTERNAL ] || [ $cmd == $CMD_BUMP_HOTFIX ] || [ $cmd == $CMD_GET_VERSION ]; then
- return
- fi
-
- showUsage
-}
-
-# Check version length, format and coherency.
-# Also creates the internal version if it doesn't exists
-function verifyVersion() {
- nvp=( ${newVer//./ } )
-
- # Check version array has at least 2 elements
- if [ "${#nvp[@]}" -lt 2 ]; then
- showErrorMessage "Version string must contain Major and Minor numbers at least"
- exit 1
- fi
-
- # Check version array has no more than 4 elements
- if [ "${#nvp[@]}" -gt 4 ]; then
- showErrorMessage "Version string can contain no more than Major, Minor, Release and Build numbers"
- exit 1
- fi
-
- # Assign 3rd and 4th el to zero if they doesn't exist
- if [ x${nvp[2]} == x ]; then
- nvp[2]=0
- fi
-
- if [ x${nvp[3]} == x ]; then
- nvp[3]=0
- fi
-
- # Check every part is a number
- for i in "${nvp[@]}"
- do
- if ! [[ $i =~ $IS_A_NUM_RE ]] ; then
- showErrorMessage "Version value can only contains numbers"
- exit 1
- fi
- done
-
- # Create version numbers
- newVer=${nvp[0]}.${nvp[1]}.${nvp[2]}.${nvp[3]}
- newMainVer=${nvp[0]}.${nvp[1]}
- if [ ${nvp[2]} == 0 ]; then
- newShortVer=${newMainVer}
- else
- newShortVer=${newMainVer}.${nvp[2]}
- fi
- releaseBranch="$releaseBranch$newMainVer"
-
- # If internal version exists, check if has the same major, minor, release
- # otherwise, create one
- if [ x$newIntVer == x ]; then
- todayDate=`date +%Y%m%d`
- newIntVer=${newVer%.*}.$todayDate
- elif [ ${newVer%.*} != ${newIntVer%.*} ]; then
- showErrorMessage "Internal and external versions don't match."
- exit 1
- fi
-}
-
-# Verifies the command and the version
-function verifyParams() {
- verifyCommand
-
- # Skip verify for bump commands
- if [ $cmd == $CMD_BUMP_RELEASE ] || [ $cmd == $CMD_BUMP_INTERNAL ] || [ $cmd == $CMD_BUMP_HOTFIX ] || [ $cmd == $CMD_GET_VERSION ]; then
- return
- fi
-
- verifyVersion
-}
-
-# Shows the configuration the script received
-function showConfig() {
- showMessage "Current build version: $currentVer"
- showMessage "Current internal version: $currentIntVer"
- showMessage "New build version: $newVer"
- showMessage "New internal version: $newIntVer"
- showMessage "New short version: $newShortVer"
- showMessage "Release branch: $releaseBranch"
-}
-
-# Appends an init line to the log
-function startLog() {
- dateTime=`date "+%d-%m-%Y - %H:%M:%S"`
- echo "$exeName started at $dateTime" >> $logFile
-}
-
-# Appends a closing line to the log
-function stopLog() {
- dateTime=`date "+%d-%m-%Y - %H:%M:%S"`
- echo "$exeName terminated at $dateTime" >> $logFile
- echo "" >> $logFile
- echo "Log location: $logFile"
-}
-
-# Writes an error message and exits
-function stopOnError() {
- showErrorMessage "Operation failed. Aborting."
- showErrorMessage "See log for further details."
- stopLog
- exit 1
-}
-
-# Checks out develop, updates it to origin and creates the release branch
-function doBranching() {
- git checkout develop >> $logFile 2>&1 || stopOnError
- git pull origin develop >> $logFile 2>&1 || stopOnError
- git show-ref --verify --quiet "refs/heads/$releaseBranch" >> $logFile 2>&1
- if [ $? -eq 0 ]; then
- showMessage "Branch $releaseBranch already exists. Skipping creation."
- git checkout $releaseBranch >> $logFile 2>&1 || stopOnError
- git pull origin $releaseBranch >> $logFile 2>&1
- else
- git checkout -b $releaseBranch >> $logFile 2>&1 || stopOnError
-
- # Push to origin
- git push -u origin $releaseBranch >> $logFile 2>&1 || stopOnError
- fi
-}
-
-# Updates the keys in download_metadata.swift and AppStoreStrings.po
-function updateGlotPressKey() {
- dmFile="./fastlane/download_metadata.swift"
- if [ -f $dmFile ]; then
- sed -i '' "s/let glotPressWhatsNewKey.*/let glotPressWhatsNewKey = \"v$newMainVer-whats-new\"/" $dmFile
- else
- showErrorMessage "Can't find $dmFile."
- stopOnError
- fi
-}
-
-# Updates the app version in Fastlane Deliver file
-function updateFastlaneDeliver() {
- fdFile="./fastlane/Deliverfile"
- if [ -f $fdFile ]; then
- sed -i '' "s/app_version.*/app_version \"$newShortVer\"/" $fdFile
- else
- showErrorMessage "Can't find $fdFile."
- stopOnError
- fi
-}
-
-# Updates a list of config files with the provided version
-function updateConfigFiles() {
- declare -a fileList=("${!1}")
- updateVer=$2
-
- for i in "${fileList[@]}"
- do
- cFile="../config/$i"
- if [ -f "$cFile" ]; then
- echo "Updating $cFile to version $2" >> $logFile 2>&1
- sed -i '' "$(awk '/^VERSION_SHORT/{ print NR; exit }' "$cFile")s/=.*/=$newShortVer/" "$cFile" >> $logFile 2>&1 || stopOnError
- sed -i '' "$(awk '/^VERSION_LONG/{ print NR; exit }' "$cFile")s/=.*/=$updateVer/" "$cFile" >> $logFile 2>&1 || stopOnError
- else
- stopOnError "$cFile not found"
- fi
- done
-}
-
-# Updates the config files
-function updateXcConfigs() {
- updateConfigFiles publicConfig[@] "$newVer"
- updateConfigFiles internalConfig[@] "$newIntVer"
-}
-
-# Updates config files and fastlane deliver on the current branch
-function updateBranch() {
- if [ $cmd == $CMD_UPDATE ]; then
- startLog
- checkVersions
- showTitleMessage "Updating the current branch to version $newMainVer..."
- showConfig
- fi
-
- showMessage "Updating Fastlane deliver file..."
- updateFastlaneDeliver
- showMessage "Done!"
- showMessage "Updating XcConfig..."
- updateXcConfigs
- showMessage "Done!"
-
- if [ $cmd == $CMD_UPDATE ]; then
- showOkMessage "Success!"
- stopLog
- fi
-}
-
-# Creates a new branch for the release and updates the relevant files
-function createBranch() {
- startLog
- if [ $cmd != $CMD_FORCE ]; then
- checkVersions
- showTitleMessage "Creating new Release branch for version $newMainVer..."
- else
- showTitleMessage "Forcing branch for version $newMainVer..."
- fi
- showConfig
- doBranching
- showMessage "Done!"
- showMessage "Updating glotPressKeys..."
- updateGlotPressKey
- showMessage "Done!"
- updateBranch
- showOkMessage "Success!"
- stopLog
-}
-
-# Reads a version from a config file
-function readVersion() {
- cFile="../config/$1"
- if [ -f "$cFile" ]; then
- tmp=$(sed -n "$(awk '/^VERSION_LONG/{ print NR; exit }' "$cFile")p" "$cFile" | cut -d'=' -f 2)
- else
- showErrorMessage "$cFile not found. Can't read version. Are you in the correct branch/folder?"
- exit 1
- fi
-}
-
-# Reads the current internal and external versions
-function getCurrentVersions() {
- printf "Reading current version in this branch..."
- readVersion ${publicConfig[0]}
- currentVer=$tmp
-
- readVersion ${internalConfig[0]}
- currentIntVer=$tmp
- echo "Done."
-}
-
-# Check coherency between current and updating version
-function checkVersion() {
- firstVer=$1
- secondVer=$2
- if [ $firstVer == $secondVer ]; then
- showErrorMessage "Current branch is already on version $firstVer"
- stopOnError
- fi
-
- nvp=( ${firstVer//./ } )
- cvp=( ${secondVer//./ } )
-
- idx=0
- for i in "${nvp[@]}"
- do
- if [ $i -gt ${cvp[idx]} ]; then
- return
- elif [ $i -lt ${cvp[idx]} ]; then
- showErrorMessage "New version $firstVer is lower than current version $secondVer"
- stopOnError
- fi
- ((idx++))
- done
-}
-
-# Check coherency between current and updating versions
-function checkVersions() {
- checkVersion $newVer $currentVer
- checkVersion $newIntVer $currentIntVer
-}
-
-# Check that the current branch name contains the provided string
-function checkBranch() {
- btover=$1
- branch_name=$(git symbolic-ref -q HEAD)
- if [[ $branch_name = *"$btover"* ]]; then
- return
- fi
-
- showErrorMessage "This command works only on $1 branch"
- stopOnError
-}
-
-# Bump current release number (only on develop branch)
-function bumpRelease() {
- checkBranch "develop"
-
- # Bump release
- showMessage "Current version: $currentVer"
- cvp=( ${currentVer//./ } )
-
- # Bump minor
- cvp[1]=$((${cvp[1]}+1))
- if [ ${cvp[1]} == 10 ]; then
- cvp[1]=0
- cvp[0]=$((${cvp[0]}+1))
- fi
-
- newVer=${cvp[0]}.${cvp[1]}
- verifyVersion
- createBranch
-}
-
-# Bump hotfix digit (only on release branch)
-function bumpHotFix {
- checkBranch "release"
-
- # Bump release
- showMessage "Current version: $currentVer"
- cvp=( ${currentVer//./ } )
-
- cvp[2]=$((${cvp[2]}+1))
- newVer=${cvp[0]}.${cvp[1]}.${cvp[2]}
- verifyVersion
- showConfig
- updateBranch
-}
-
-#Bump internal digit (only on release branch)
-function bumpInternal {
- checkBranch "release"
-
- # Bump release
- showMessage "Current version: $currentVer"
- cvp=( ${currentVer//./ } )
-
- cvp[3]=$((${cvp[3]}+1))
- newVer=${cvp[0]}.${cvp[1]}.${cvp[2]}.${cvp[3]}
- verifyVersion
- showConfig
- updateBranch
-}
-
-### Script main
-exeName=$(basename "$0" ".sh")
-
-# Params check
-if [ "$#" -lt 1 ] || [ "$#" -gt 3 ] || [ -z $1 ]; then
- showUsage
-fi
-
-# Load params
-cmd=$1
-newVer=$2
-newIntVer=$3
-newMainVer=0
-newShortVer=0
-currentVer=0
-currentIntVer=0
-releaseBranch="release/"
-logFile="/tmp/manage-version.log"
-
-verifyParams
-getCurrentVersions
-
-if [ $cmd == $CMD_CREATE ] || [ $cmd == $CMD_FORCE ]; then
- createBranch
-elif [ $cmd == $CMD_UPDATE ]; then
- updateBranch
-elif [ $cmd == $CMD_BUMP_RELEASE ]; then
- bumpRelease
-elif [ $cmd == $CMD_BUMP_HOTFIX ]; then
- bumpHotFix
-elif [ $cmd == $CMD_BUMP_INTERNAL ]; then
- bumpInternal
-elif [ $cmd == $CMD_GET_VERSION ]; then
- echo $currentVer
- echo $currentIntVer
-else
- showUsage
-fi
diff --git a/Scripts/run-oclint.sh b/Scripts/run-oclint.sh
deleted file mode 100755
index 8a72dca8c1b1..000000000000
--- a/Scripts/run-oclint.sh
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/bin/sh
-source ~/.bash_profile
-check_file="$1"
-oclint_args="-disable-rule=ShortVariableName -disable-rule=LongLine -disable-rule=LongClass -disable-rule=LongMethod -disable-rule=UnusedMethodParameter -disable-rule=LongVariableName"
-temp_dir="/tmp"
-build_dir="${temp_dir}/WPiOS_linting"
-compile_commands_path=${temp_dir}/compile_commands.json
-xcodebuild_log_path=${temp_dir}/xcodebuild.log
-
-hash oclint &> /dev/null
-if [ $? -eq 1 ]; then
- echo >&2 "[OCLint] oclint not found, analyzing stopped"
- exit 1
-fi
-
-oclint --version
-
-echo "[OCLint] cleaning up generated files"
-[[ -f $compile_commands_path ]] && rm ${compile_commands_path}
-[[ -f $xcodebuild_log_path ]] && rm ${xcodebuild_log_path}
-
-echo "[OCLint] starting xcodebuild to build the project.."
-if [ -d WordPress.xcworkspace ]; then
- echo "[OCLint] we're running the script from the CLI"
- xcode_workspace="WordPress.xcworkspace"
- if [ ! $TRAVIS ]; then
- oclint_args+=" -report-type=html -o=oclint_result.html"
- fi
- pipe_command=""
-elif [ -d ../WordPress.xcworkspace ]; then
- echo "[OCLint] we're running the script from Xcode"
- xcode_workspace="../WordPress.xcworkspace"
- pipe_command="| sed 's/\\(.*\\.\\m\\{1,2\\}:[0-9]*:[0-9]*:\\)/\\1 warning:/'"
-else
- # error!
- echo >&2 "[OCLint] workspace not found, analyzing stopped"
- exit 1
-fi
-
-echo "[OCLint] cleaning project"
-xctool clean \
- -sdk "iphonesimulator8.4" \
- -workspace $xcode_workspace -configuration Debug -scheme WordPress \
- CONFIGURATION_BUILD_DIR=$build_dir \
- DSTROOT=$build_dir OBJROOT=$build_dir SYMROOT=$build_dir \
- reporter pretty \
- > ${temp_dir}/clean.log
-
-echo "[OCLint] building project"
-xctool build \
- -sdk "iphonesimulator8.4" \
- CONFIGURATION_BUILD_DIR=$build_dir \
- -workspace $xcode_workspace -configuration Debug -scheme WordPress \
- DSTROOT=$build_dir OBJROOT=$build_dir SYMROOT=$build_dir \
- -reporter json-compilation-database:$compile_commands_path
-
-
-if [ $TRAVIS ]; then
- echo "[OCLint] only files changed on push";
- include_files=`git diff $TRAVIS_COMMIT_RANGE --name-only | grep '\.m' | tr '\n' '|' | sed 's/|*$/"/g'`
- exclude_files="-e Pods/ -e Vendor/ -e WordPressTodayWidget/ -e SFHFKeychainUtils.m -e Constants.m"
- base_commit=`echo $TRAVIS_COMMIT_RANGE | cut -d '.' -f 1`
- base_commit+="^"
- sha=`echo $TRAVIS_COMMIT_RANGE | cut -d '.' -f 4`
- full_sha=`git rev-parse $sha`
- echo $full_sha
- if [ ! -z "$include_files" ]; then
- include_files=' -i "'$include_files
- else
- exclude_files="-e *"
- fi
- echo "[OCLint] analyzing these files: $include_files"
-elif [[ $1 == "DIFF" ]]; then
- include_files=`git diff HEAD^ --name-only | grep '\.m' | tr '\n' '|' | sed 's/|*$/"/g'`
- include_files=' -i "'$include_files
- echo "[OCLint] only looking at this files: $include_files"
- exclude_files="-e Pods/ -e Vendor/ -e WordPressTodayWidget/ -e SFHFKeychainUtils.m -e Constants.m"
-elif [ $1 ]; then
- include_files="-i ${check_file}"
- exclude_files="-e Pods/ -e Vendor/ -e WordPressTodayWidget/ -e SFHFKeychainUtils.m -e Constants.m"
-else
- echo "[OCLint] Looking at all files"
- include_files=""
- exclude_files="-e Pods/ -e Vendor/ -e WordPressTodayWidget/ -e SFHFKeychainUtils.m -e Constants.m"
-fi
-
-#echo "[*] transforming xcodebuild.log into compile_commands.json..."
-cd ${temp_dir}
-#oclint-xcodebuild -e Pods/ -o ${compile_commands_path}
-
-echo "[OCLint] starting analyzing"
-
-if [ $TRAVIS ]; then
- eval "oclint-json-compilation-database $exclude_files $include_files oclint_args \"$oclint_args\" " > currentLint.log
- cat currentLint.log
- cd ${TRAVIS_BUILD_DIR}
- git checkout $base_commit
- cd ${temp_dir}
- eval "oclint-json-compilation-database $exclude_files $include_files oclint_args \"$oclint_args\" " > baseLint.log
- currentSummary=`cat currentLint.log | grep "Summary: "`
- baseSummary=`cat baseLint.log | grep "Summary: "`
- regex='P1=([[:digit:]]*) P2=([[:digit:]]*) P3=([[:digit:]]*)'
- if [[ $currentSummary =~ $regex ]]; then
- currentTotalSummary=( ${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]})
- fi
- if [[ $baseSummary =~ $regex ]]; then
- baseTotalSummary=( ${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]})
- fi
- errors=0;
- i=0
- n=3
- message=""
- while [[ $i -lt $n ]]
- do
- if [[ currentTotalSummary[$i] -ge baseTotalSummary[$i] ]]; then
- amount=$((${currentTotalSummary[$i]} - ${baseTotalSummary[$i]}))
- errors+=$amount
- message+=" P"$(($i+1))"=+"$amount
- echo "[OCLint] Your changes introduced "$amount "P"$(($i+1))" issue(s)"
- else
- amount=$((${baseTotalSummary[$i]} - ${currentTotalSummary[$i]}))
- message+=" P"$(($i+1))"=-"$amount
- echo "[OCLint] Your changes removed "$amount "P"$(($i+1))" issue(s)"
- fi
- let i++
- done
-
- # going back to original push commit.
- cd ${TRAVIS_BUILD_DIR}
- git checkout $TRAVIS_COMMIT
-
- # sending message to github
- travis_url="https://travis-ci.org/${TRAVIS_REPO_SLUG}/builds/${TRAVIS_BUILD_ID}/"
- echo $travis_url
- if [[ $errors -eq 0 ]]; then
- state="success"
- message="OK "$message
- else
- state="failure"
- message="Failed "$message
- fi
- curl -i -H "Content-Type: application/json" \
- -H "Authorization: token ${TRAVIS_OCLINT_GITHUB_TOKEN}" \
- -d "{\"state\": \"${state}\",\"target_url\": \"${travis_url}\",\"description\": \"${message}\",\"context\": \"oclint\"}" \
- https://api.github.com/repos/${TRAVIS_REPO_SLUG}/statuses/$full_sha
-
- exit 0
-else
- eval "oclint-json-compilation-database $exclude_files $include_files oclint_args \"$oclint_args\" $pipe_command"
- echo "[OCLint] showing results"
- if [ -d oclint_result.html ]; then
- open oclint_result.html
- fi
- exit $?
-fi
diff --git a/Scripts/runUITests.sh b/Scripts/runUITests.sh
deleted file mode 100755
index 81cc1af77afb..000000000000
--- a/Scripts/runUITests.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/sh
-
-#checking if xcpretty is available to use
-pretty="xcpretty"
-command -v xcpretty >/dev/null
-if [ $? -eq 1 ]; then
-echo >&2 "xcpretty not found don't use it."
-pretty="&>";
-fi
-#run tests using iPhone 6 simulator on iOS 8
-xcodebuild test -workspace WordPress.xcworkspace -scheme WordPressUITests -sdk iphonesimulator10.2 -destination 'platform=iOS Simulator,name=iPhone 7' | ${pretty}
diff --git a/Scripts/update-translations.rb b/Scripts/update-translations.rb
deleted file mode 100755
index 6701a96484b5..000000000000
--- a/Scripts/update-translations.rb
+++ /dev/null
@@ -1,118 +0,0 @@
-#!/usr/bin/env ruby
-# encoding: utf-8
-
-# Supported languages:
-# ar,ca,cs,cy,da,de,el,en,en-CA,en-GB,es,fi,fr,he,hr,hu,id,it,ja,ko,ms,nb,nl,pl,pt,pt-PT,ro,ru,sk,sv,th,tr,uk,vi,zh-Hans,zh-Hant
-# * Arabic
-# * Catalan
-# * Czech
-# * Danish
-# * German
-# * Greek
-# * English
-# * English (Canada eh)
-# * English (UK)
-# * Spanish
-# * Finnish
-# * French
-# * Hebrew
-# * Croatian
-# * Hungarian
-# * Indonesian
-# * Italian
-# * Japanese
-# * Korean
-# * Malay
-# * Norwegian (Bokmål)
-# * Dutch
-# * Polish
-# * Portuguese
-# * Portuguese (Portugal)
-# * Romanian
-# * Russian
-# * Slovak
-# * Swedish
-# * Thai
-# * Turkish
-# * Ukranian
-# * Vietnamese
-# * Chinese (China) [zh-Hans]
-# * Chinese (Taiwan) [zh-Hant]
-# * Welsh
-
-if Dir.pwd =~ /Scripts/
- puts "Must run script from root folder"
- exit
-end
-
-ALL_LANGS={
- 'ar' => 'ar', # Arabic
- 'bg' => 'bg', # Bulgarian
- 'cs' => 'cs', # Czech
- 'cy' => 'cy', # Welsh
- 'da' => 'da', # Danish
- 'de' => 'de', # German
- 'en-au' => 'en-AU', # English (Australia)
- 'en-ca' => 'en-CA', # English (Canada)
- 'en-gb' => 'en-GB', # English (UK)
- 'es' => 'es', # Spanish
- 'fr' => 'fr', # French
- 'he' => 'he', # Hebrew
- 'hr' => 'hr', # Croatian
- 'hu' => 'hu', # Hungarian
- 'id' => 'id', # Indonesian
- 'is' => 'is', # Icelandic
- 'it' => 'it', # Italian
- 'ja' => 'ja', # Japanese
- 'ko' => 'ko', # Korean
- 'nb' => 'nb', # Norwegian (Bokmål)
- 'nl' => 'nl', # Dutch
- 'pl' => 'pl', # Polish
- 'pt' => 'pt', # Portuguese
- 'pt-br' => 'pt-BR', # Portuguese (Brazil)
- 'ro' => 'ro', # Romainian
- 'ru' => 'ru', # Russian
- 'sk' => 'sk', # Slovak
- 'sq' => 'sq', # Albanian
- 'sv' => 'sv', # Swedish
- 'th' => 'th', # Thai
- 'tr' => 'tr', # Turkish
- 'zh-cn' => 'zh-Hans', # Chinese (China)
- 'zh-tw' => 'zh-Hant', # Chinese (Taiwan)
-}
-
-langs = {}
-if ARGV.count > 0
- for key in ARGV
- unless local = ALL_LANGS[key]
- puts "Unknown language #{key}"
- exit 1
- end
- langs[key] = local
- end
-else
- langs = ALL_LANGS
-end
-
-langs.each do |code,local|
- lang_dir = File.join('WordPress', 'Resources', "#{local}.lproj")
- puts "Updating #{code}"
- system "mkdir -p #{lang_dir}"
- system "if [ -e #{lang_dir}/Localizable.strings ]; then cp #{lang_dir}/Localizable.strings #{lang_dir}/Localizable.strings.bak; fi"
-
- url = "https://translate.wordpress.org/projects/apps/ios/dev/#{code}/default/export-translations?format=strings"
- destination = "#{lang_dir}/Localizable.strings"
-
- system "curl -fLso #{destination} #{url}" or begin
- puts "Error downloading #{code}"
- end
-
- if File.size(destination).to_f == 0
- abort("\e[31mFatal Error: #{destination} appears to be empty. Exiting.\e[0m")
- end
-
- system "./Scripts/fix-translation #{lang_dir}/Localizable.strings"
- system "plutil -lint #{lang_dir}/Localizable.strings" and system "rm #{lang_dir}/Localizable.strings.bak"
- system "grep -a '\\x00\\x20\\x00\\x22\\x00\\x22\\x00\\x3b$' #{lang_dir}/Localizable.strings"
-end
-system "Scripts/extract-framework-translations.swift"
diff --git a/WordPress.xcworkspace/contents.xcworkspacedata b/WordPress.xcworkspace/contents.xcworkspacedata
index be0a88cc2010..eca405337189 100644
--- a/WordPress.xcworkspace/contents.xcworkspacedata
+++ b/WordPress.xcworkspace/contents.xcworkspacedata
@@ -5,7 +5,7 @@
location = "group:WordPress/WordPress.xcodeproj">
+ location = "group:WordPressFlux">
diff --git a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 000000000000..cfdb51af1b5f
--- /dev/null
+++ b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,106 @@
+{
+ "object": {
+ "pins": [
+ {
+ "package": "AutomatticAbout",
+ "repositoryURL": "https://github.com/automattic/AutomatticAbout-swift",
+ "state": {
+ "branch": null,
+ "revision": "0f784591b324e5d3ddc5771808ef8eca923e3de2",
+ "version": "1.1.2"
+ }
+ },
+ {
+ "package": "Charts",
+ "repositoryURL": "https://github.com/danielgindi/Charts",
+ "state": {
+ "branch": null,
+ "revision": "07b23476ad52b926be772f317d8f1d4511ee8d02",
+ "version": "4.1.0"
+ }
+ },
+ {
+ "package": "CwlCatchException",
+ "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git",
+ "state": {
+ "branch": null,
+ "revision": "35f9e770f54ce62dd8526470f14c6e137cef3eea",
+ "version": "2.1.1"
+ }
+ },
+ {
+ "package": "CwlPreconditionTesting",
+ "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git",
+ "state": {
+ "branch": null,
+ "revision": "c21f7bab5ca8eee0a9998bbd17ca1d0eb45d4688",
+ "version": "2.1.0"
+ }
+ },
+ {
+ "package": "Lottie",
+ "repositoryURL": "https://github.com/airbnb/lottie-ios.git",
+ "state": {
+ "branch": null,
+ "revision": "4ca8023b820b7d5d5ae1e2637c046e3dab0f45d0",
+ "version": "3.4.2"
+ }
+ },
+ {
+ "package": "Nimble",
+ "repositoryURL": "https://github.com/Quick/Nimble",
+ "state": {
+ "branch": null,
+ "revision": "1f3bde57bde12f5e7b07909848c071e9b73d6edc",
+ "version": "10.0.0"
+ }
+ },
+ {
+ "package": "ScreenObject",
+ "repositoryURL": "https://github.com/Automattic/ScreenObject",
+ "state": {
+ "branch": null,
+ "revision": "cb38a32bbcc733ba03e307ca7bcae63f8c5de729",
+ "version": "0.2.2"
+ }
+ },
+ {
+ "package": "swift-algorithms",
+ "repositoryURL": "https://github.com/apple/swift-algorithms",
+ "state": {
+ "branch": null,
+ "revision": "b14b7f4c528c942f121c8b860b9410b2bf57825e",
+ "version": "1.0.0"
+ }
+ },
+ {
+ "package": "swift-numerics",
+ "repositoryURL": "https://github.com/apple/swift-numerics",
+ "state": {
+ "branch": null,
+ "revision": "0a5bc04095a675662cf24757cc0640aa2204253b",
+ "version": "1.0.2"
+ }
+ },
+ {
+ "package": "BuildkiteTestCollector",
+ "repositoryURL": "https://github.com/buildkite/test-collector-swift",
+ "state": {
+ "branch": null,
+ "revision": "77c7f492f5c1c9ca159f73d18f56bbd1186390b0",
+ "version": "0.3.0"
+ }
+ },
+ {
+ "package": "XCUITestHelpers",
+ "repositoryURL": "https://github.com/Automattic/XCUITestHelpers",
+ "state": {
+ "branch": null,
+ "revision": "5179cb69d58b90761cc713bdee7740c4889d3295",
+ "version": "0.4.0"
+ }
+ }
+ ]
+ },
+ "version": 1
+}
diff --git a/WordPress/Classes/Categories/Media+WPMediaAsset.m b/WordPress/Classes/Categories/Media+WPMediaAsset.m
index 43dc6c8803ad..18e89d8c33a2 100644
--- a/WordPress/Classes/Categories/Media+WPMediaAsset.m
+++ b/WordPress/Classes/Categories/Media+WPMediaAsset.m
@@ -1,7 +1,7 @@
#import "Media+WPMediaAsset.h"
#import "MediaService.h"
#import "Blog.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "WordPress-Swift.h"
@implementation Media(WPMediaAsset)
@@ -47,9 +47,16 @@ - (WPMediaRequestID)videoAssetWithCompletionHandler:(WPMediaAssetBlock)completio
if (!url && self.videopressGUID.length > 0 ){
NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] mainContext];
MediaService *mediaService = [[MediaService alloc] initWithManagedObjectContext:mainContext];
- [mediaService getMediaURLFromVideoPressID:self.videopressGUID inBlog:self.blog success:^(NSString *videoURL, NSString *posterURL) {
+ [mediaService getMetadataFromVideoPressID: self.videopressGUID inBlog:self.blog success:^(RemoteVideoPressVideo *metadata) {
// Let see if can create an asset with this url
- AVURLAsset *asset = [AVURLAsset assetWithURL:[NSURL URLWithString:videoURL]];
+ NSURL *originalURL = metadata.originalURL;
+ if (!originalURL) {
+ NSString *errorMessage = NSLocalizedString(@"Selected media is unavailable.", @"Error message when user tries a no longer existent video media object.");
+ completionHandler(nil, [self errorWithMessage:errorMessage]);
+ return;
+ }
+ NSURL *videoURL = [metadata getURLWithToken:originalURL] ?: originalURL;
+ AVURLAsset *asset = [AVURLAsset assetWithURL:videoURL];
if (!asset) {
NSString *errorMessage = NSLocalizedString(@"Selected media is unavailable.", @"Error message when user tries a no longer existent video media object.");
completionHandler(nil, [self errorWithMessage:errorMessage]);
diff --git a/WordPress/Classes/Categories/NSAttributedString+Util.h b/WordPress/Classes/Categories/NSAttributedString+Util.h
deleted file mode 100644
index 4a320be5affc..000000000000
--- a/WordPress/Classes/Categories/NSAttributedString+Util.h
+++ /dev/null
@@ -1,9 +0,0 @@
-#import
-
-
-
-@interface NSMutableAttributedString (Util)
-
-- (void)applyAttributesToQuotes:(NSDictionary *)attributes;
-
-@end
diff --git a/WordPress/Classes/Categories/NSAttributedString+Util.m b/WordPress/Classes/Categories/NSAttributedString+Util.m
deleted file mode 100644
index 0e083044b069..000000000000
--- a/WordPress/Classes/Categories/NSAttributedString+Util.m
+++ /dev/null
@@ -1,22 +0,0 @@
-#import "NSAttributedString+Util.h"
-#import "NSScanner+Helpers.h"
-
-
-
-@implementation NSMutableAttributedString (Util)
-
-- (void)applyAttributesToQuotes:(NSDictionary *)attributes
-{
- NSString *rawText = self.string;
- NSScanner *scanner = [NSScanner scannerWithString:rawText];
- NSArray *quotes = [scanner scanQuotedText];
-
- for (NSString *quote in quotes) {
- NSRange itemRange = [rawText rangeOfString:quote];
- if (itemRange.location != NSNotFound) {
- [self addAttributes:attributes range:itemRange];
- }
- }
-}
-
-@end
diff --git a/WordPress/Classes/Categories/NSObject+Helpers.h b/WordPress/Classes/Categories/NSObject+Helpers.h
index 10df519e4a78..65607f071262 100644
--- a/WordPress/Classes/Categories/NSObject+Helpers.h
+++ b/WordPress/Classes/Categories/NSObject+Helpers.h
@@ -1,9 +1,12 @@
#import
-
+NS_ASSUME_NONNULL_BEGIN
@interface NSObject (Helpers)
-+ (nonnull NSString *)classNameWithoutNamespaces;
++ (NSString *)classNameWithoutNamespaces;
+- (void)debounce:(SEL)selector afterDelay:(NSTimeInterval)timeInterval;
@end
+
+NS_ASSUME_NONNULL_END
diff --git a/WordPress/Classes/Categories/NSObject+Helpers.m b/WordPress/Classes/Categories/NSObject+Helpers.m
index bbb02b7e40f0..ca49efa20aea 100644
--- a/WordPress/Classes/Categories/NSObject+Helpers.m
+++ b/WordPress/Classes/Categories/NSObject+Helpers.m
@@ -11,4 +11,14 @@ + (NSString *)classNameWithoutNamespaces
return [[NSStringFromClass(self) componentsSeparatedByString:@"."] lastObject];
}
+- (void)debounce:(SEL)selector afterDelay:(NSTimeInterval)timeInterval
+{
+ __weak __typeof(self) weakSelf = self;
+ [NSObject cancelPreviousPerformRequestsWithTarget:weakSelf
+ selector:selector
+ object:nil];
+ [weakSelf performSelector:selector
+ withObject:nil
+ afterDelay:timeInterval];
+}
@end
diff --git a/WordPress/Classes/Categories/NSScanner+Helpers.h b/WordPress/Classes/Categories/NSScanner+Helpers.h
deleted file mode 100644
index 461c702571c0..000000000000
--- a/WordPress/Classes/Categories/NSScanner+Helpers.h
+++ /dev/null
@@ -1,9 +0,0 @@
-#import
-
-
-
-@interface NSScanner (Helpers)
-
-- (NSArray *)scanQuotedText;
-
-@end
diff --git a/WordPress/Classes/Categories/NSScanner+Helpers.m b/WordPress/Classes/Categories/NSScanner+Helpers.m
deleted file mode 100644
index 08c7847929dd..000000000000
--- a/WordPress/Classes/Categories/NSScanner+Helpers.m
+++ /dev/null
@@ -1,26 +0,0 @@
-#import "NSScanner+Helpers.h"
-
-
-
-@implementation NSScanner (Helpers)
-
-- (NSArray *)scanQuotedText
-{
- NSMutableArray *scanned = [NSMutableArray array];
- NSString *quote = nil;
-
- while ([self isAtEnd] == NO) {
- [self scanUpToString:@"\"" intoString:nil];
- [self scanString:@"\"" intoString:nil];
- [self scanUpToString:@"\"" intoString:"e];
- [self scanString:@"\"" intoString:nil];
-
- if (quote.length) {
- [scanned addObject:quote];
- }
- }
-
- return scanned;
-}
-
-@end
diff --git a/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.h b/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.h
new file mode 100644
index 000000000000..d387ec7b8109
--- /dev/null
+++ b/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.h
@@ -0,0 +1,19 @@
+#import
+
+@class Blog;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface UIViewController (RemoveQuickStart)
+
+
+/// Displays an action sheet with an option to remove current quickstart tours from the provided blog.
+/// Displayed as an action sheet on iPhone and as a popover on iPad
+/// @param blog Blog to remove quickstart from
+/// @param sourceView View used as sourceView for the sheet's popoverPresentationController
+/// @param sourceRect rect used as sourceRect for the sheet's popoverPresentationController
+- (void)removeQuickStartFromBlog:(Blog *)blog sourceView:(UIView *)sourceView sourceRect:(CGRect)sourceRect;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.m b/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.m
new file mode 100644
index 000000000000..f4edb6f2d68b
--- /dev/null
+++ b/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.m
@@ -0,0 +1,40 @@
+#import "UIViewController+RemoveQuickStart.h"
+
+#import "Blog.h"
+#import "WordPress-Swift.h"
+
+@implementation UIViewController (RemoveQuickStart)
+
+- (void)removeQuickStartFromBlog:(Blog *)blog sourceView:(UIView *)sourceView sourceRect:(CGRect)sourceRect
+{
+ [NoticesDispatch lock];
+ NSString *removeTitle = NSLocalizedString(@"Remove Next Steps", @"Title for action that will remove the next steps/quick start menus.");
+ NSString *removeMessage = NSLocalizedString(@"Removing Next Steps will hide all tours on this site. This action cannot be undone.", @"Explanation of what will happen if the user confirms this alert.");
+ NSString *confirmationTitle = NSLocalizedString(@"Remove", @"Title for button that will confirm removing the next steps/quick start menus.");
+ NSString *cancelTitle = NSLocalizedString(@"Cancel", @"Cancel button");
+
+ UIAlertController *removeConfirmation = [UIAlertController alertControllerWithTitle:removeTitle message:removeMessage preferredStyle:UIAlertControllerStyleAlert];
+ [removeConfirmation addCancelActionWithTitle:cancelTitle handler:^(UIAlertAction * _Nonnull __unused action) {
+ [WPAnalytics trackQuickStartStat:WPAnalyticsStatQuickStartRemoveDialogButtonCancelTapped blog: blog];
+ [NoticesDispatch unlock];
+ }];
+ [removeConfirmation addDefaultActionWithTitle:confirmationTitle handler:^(UIAlertAction * _Nonnull __unused action) {
+ [WPAnalytics trackQuickStartStat:WPAnalyticsStatQuickStartRemoveDialogButtonRemoveTapped blog: blog];
+ [[QuickStartTourGuide shared] removeFrom:blog];
+ [NoticesDispatch unlock];
+ }];
+
+ UIAlertController *removeSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
+ removeSheet.popoverPresentationController.sourceView = sourceView;
+ removeSheet.popoverPresentationController.sourceRect = sourceRect;
+ [removeSheet addDestructiveActionWithTitle:removeTitle handler:^(UIAlertAction * _Nonnull __unused action) {
+ [self presentViewController:removeConfirmation animated:YES completion:nil];
+ }];
+ [removeSheet addCancelActionWithTitle:cancelTitle handler:^(UIAlertAction * _Nonnull __unused action) {
+ [NoticesDispatch unlock];
+ }];
+
+ [self presentViewController:removeSheet animated:YES completion:nil];
+}
+
+@end
diff --git a/WordPress/Classes/Categories/WPStyleGuide+Suggestions.m b/WordPress/Classes/Categories/WPStyleGuide+Suggestions.m
index 656c5374fc0b..145ff18ec4c6 100644
--- a/WordPress/Classes/Categories/WPStyleGuide+Suggestions.m
+++ b/WordPress/Classes/Categories/WPStyleGuide+Suggestions.m
@@ -4,7 +4,13 @@ @implementation WPStyleGuide (Suggestions)
+ (UIColor *)suggestionsHeaderSmoke
{
- return [UIColor colorWithRed:0. green:0. blue:0. alpha:0.3];
+ return [UIColor colorWithDynamicProvider:^(UITraitCollection *traitCollection) {
+ if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
+ return [UIColor colorWithRed:0. green:0. blue:0. alpha:0.7];
+ } else {
+ return [UIColor colorWithRed:0. green:0. blue:0. alpha:0.3];
+ }
+ }];
}
+ (UIColor *)suggestionsSeparatorSmoke
diff --git a/WordPress/Classes/Extensions/AbstractPost+Dates.swift b/WordPress/Classes/Extensions/AbstractPost+Dates.swift
index 63cf00d0e0b3..d5b661758a22 100644
--- a/WordPress/Classes/Extensions/AbstractPost+Dates.swift
+++ b/WordPress/Classes/Extensions/AbstractPost+Dates.swift
@@ -8,13 +8,13 @@ extension AbstractPost {
/// - **Immediately**: Displays "Publish Immediately" string
/// - **Published or Draft**: Shows relative date when < 7 days
public func displayDate() -> String? {
- let context = managedObjectContext ?? ContextManager.sharedInstance().mainContext
- let blogService = BlogService(managedObjectContext: context)
- let timeZone = blogService.timeZone(for: blog)
+ assert(self.managedObjectContext != nil)
+
+ let timeZone = blog.timeZone
// Unpublished post shows relative or date string
if originalIsDraft() || status == .pending {
- return dateModified?.mediumString(timeZone: timeZone)
+ return dateModified?.toMediumString(inTimeZone: timeZone)
}
// Scheduled Post shows date with time to be clear about when it goes live
@@ -27,6 +27,6 @@ extension AbstractPost {
return NSLocalizedString("Publish Immediately", comment: "A short phrase indicating a post is due to be immedately published.")
}
- return dateCreated?.mediumString(timeZone: timeZone)
+ return dateCreated?.toMediumString(inTimeZone: timeZone)
}
}
diff --git a/WordPress/Classes/Extensions/AbstractPost+PostInformation.swift b/WordPress/Classes/Extensions/AbstractPost+PostInformation.swift
deleted file mode 100644
index 39bc42b56466..000000000000
--- a/WordPress/Classes/Extensions/AbstractPost+PostInformation.swift
+++ /dev/null
@@ -1,27 +0,0 @@
-
-extension AbstractPost: ImageSourceInformation {
- var isPrivateOnWPCom: Bool {
- return isPrivate() && blog.isHostedAtWPcom
- }
-
- var isSelfHostedWithCredentials: Bool {
- return blog.isSelfHostedWithCredentials
- }
-
- var isLocalRevision: Bool {
- return self.originalIsDraft() && self.isRevision() && self.remoteStatus == .local
- }
-
- /// Returns true if the post is a draft and has never been uploaded to the server.
- var isLocalDraft: Bool {
- return self.isDraft() && !self.hasRemote()
- }
-
- /// An autosave revision may include post title, content and/or excerpt.
- var hasAutosaveRevision: Bool {
- guard let autosaveRevisionIdentifier = autosaveIdentifier?.intValue else {
- return false
- }
- return autosaveRevisionIdentifier > 0
- }
-}
diff --git a/WordPress/Classes/Extensions/Array+Page.swift b/WordPress/Classes/Extensions/Array+Page.swift
index cb5f3743aaaf..a1d2a4689123 100644
--- a/WordPress/Classes/Extensions/Array+Page.swift
+++ b/WordPress/Classes/Extensions/Array+Page.swift
@@ -88,6 +88,19 @@ extension Array where Element == Page {
.hierachyIndexes()
}
+ /// Moves the homepage first if it is on the top level
+ ///
+ /// - Returns: An Array of Elements
+ func setHomePageFirst() -> [Element] {
+ if let homepageIndex = self.firstIndex(where: { $0.isSiteHomepage }) {
+ var pages: [Page] = Array(self)
+ let homepage = pages.remove(at: homepageIndex)
+ pages.insert(homepage, at: 0)
+ return pages
+ }
+ return self
+ }
+
/// Remove Elements from a specific index
///
/// - Parameter index: The starting index
diff --git a/WordPress/Classes/Extensions/Binding+OnChange.swift b/WordPress/Classes/Extensions/Binding+OnChange.swift
new file mode 100644
index 000000000000..d5b3c0382db7
--- /dev/null
+++ b/WordPress/Classes/Extensions/Binding+OnChange.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+extension Binding {
+ func onChange(_ handler: @escaping (Value) -> Void) -> Binding {
+ Binding(
+ get: { self.wrappedValue },
+ set: { newValue in
+ self.wrappedValue = newValue
+ handler(newValue)
+ }
+ )
+ }
+}
diff --git a/WordPress/Classes/Extensions/Blog+ImageSourceInformation.swift b/WordPress/Classes/Extensions/Blog+ImageSourceInformation.swift
deleted file mode 100644
index 5e1c8f4125ed..000000000000
--- a/WordPress/Classes/Extensions/Blog+ImageSourceInformation.swift
+++ /dev/null
@@ -1,10 +0,0 @@
-
-extension Blog: ImageSourceInformation {
- var isPrivateOnWPCom: Bool {
- return isHostedAtWPcom && isPrivate()
- }
-
- var isSelfHostedWithCredentials: Bool {
- return !isHostedAtWPcom && isBasicAuthCredentialStored()
- }
-}
diff --git a/WordPress/Classes/Extensions/Bool+StringRepresentation.swift b/WordPress/Classes/Extensions/Bool+StringRepresentation.swift
new file mode 100644
index 000000000000..f7ba822bd9bf
--- /dev/null
+++ b/WordPress/Classes/Extensions/Bool+StringRepresentation.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+extension Bool {
+ var stringLiteral: String {
+ self ? "true" : "false"
+ }
+}
diff --git a/WordPress/Classes/Extensions/CollectionType+Helpers.swift b/WordPress/Classes/Extensions/CollectionType+Helpers.swift
deleted file mode 100644
index 997d7749b626..000000000000
--- a/WordPress/Classes/Extensions/CollectionType+Helpers.swift
+++ /dev/null
@@ -1,18 +0,0 @@
-import Foundation
-
-extension BidirectionalCollection {
- public func lastIndex(where predicate: (Self.Iterator.Element) throws -> Bool) rethrows -> Self.Index? {
- if let idx = try reversed().firstIndex(where: predicate) {
- return self.index(before: idx.base)
- }
- return nil
- }
-}
-
-extension Collection {
-
- /// Returns the element at the specified index if it is within bounds, otherwise nil.
- subscript (safe index: Index) -> Element? {
- return indices.contains(index) ? self[index] : nil
- }
-}
diff --git a/WordPress/Classes/Extensions/Colors and Styles/MurielColor.swift b/WordPress/Classes/Extensions/Colors and Styles/MurielColor.swift
index b1aa91ea8bba..9ec538d8ea10 100644
--- a/WordPress/Classes/Extensions/Colors and Styles/MurielColor.swift
+++ b/WordPress/Classes/Extensions/Colors and Styles/MurielColor.swift
@@ -11,6 +11,7 @@ enum MurielColorName: String, CustomStringConvertible {
case purple
case red
case yellow
+ case jetpackGreen
var description: String {
// can't use .capitalized because it lowercases the P and B in "wordPressBlue"
@@ -57,16 +58,18 @@ struct MurielColor {
}
// MARK: - Muriel's semantic colors
- static let accent = MurielColor(name: .pink)
- static let brand = MurielColor(name: .wordPressBlue)
- static let divider = MurielColor(name: .gray, shade: .shade10)
- static let error = MurielColor(name: .red)
- static let gray = MurielColor(name: .gray)
- static let primary = MurielColor(name: .blue)
- static let success = MurielColor(name: .green)
- static let text = MurielColor(name: .gray, shade: .shade80)
- static let textSubtle = MurielColor(name: .gray, shade: .shade50)
- static let warning = MurielColor(name: .yellow)
+ static let accent = AppStyleGuide.accent
+ static let brand = AppStyleGuide.brand
+ static let divider = AppStyleGuide.divider
+ static let error = AppStyleGuide.error
+ static let gray = AppStyleGuide.gray
+ static let primary = AppStyleGuide.primary
+ static let success = AppStyleGuide.success
+ static let text = AppStyleGuide.text
+ static let textSubtle = AppStyleGuide.textSubtle
+ static let warning = AppStyleGuide.warning
+ static let jetpackGreen = AppStyleGuide.jetpackGreen
+ static let editorPrimary = AppStyleGuide.editorPrimary
/// The full name of the color, with required shade value
func assetName() -> String {
diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColors.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColors.swift
index 85cdbc20f605..a6e962a4ebfd 100644
--- a/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColors.swift
+++ b/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColors.swift
@@ -21,6 +21,16 @@ extension UIColor {
let newColor = MurielColor(from: color, shade: shade)
return muriel(color: newColor)
}
+
+ /// Get a UIColor from the Muriel color palette by name, adjusted to a given shade
+ /// - Parameters:
+ /// - name: a MurielColorName
+ /// - shade: a MurielColorShade
+ /// - Returns: the desired color/shade
+ class func muriel(name: MurielColorName, _ shade: MurielColorShade) -> UIColor {
+ let newColor = MurielColor(name: name, shade: shade)
+ return muriel(color: newColor)
+ }
}
// MARK: - Basic Colors
extension UIColor {
@@ -52,6 +62,12 @@ extension UIColor {
return muriel(color: .primary, shade)
}
+ /// Muriel editor primary color
+ static var editorPrimary = muriel(color: .editorPrimary)
+ class func editorPrimary(_ shade: MurielColorShade) -> UIColor {
+ return muriel(color: .editorPrimary, shade)
+ }
+
/// Muriel success color
static var success = muriel(color: .success)
class func success(_ shade: MurielColorShade) -> UIColor {
@@ -63,6 +79,9 @@ extension UIColor {
class func warning(_ shade: MurielColorShade) -> UIColor {
return muriel(color: .warning, shade)
}
+
+ /// Muriel jetpack green color
+ static var jetpackGreen = muriel(color: .jetpackGreen)
}
// MARK: - Grays
@@ -112,212 +131,184 @@ extension UIColor {
extension UIColor {
/// The most basic background: white in light mode, black in dark mode
static var basicBackground: UIColor {
- if #available(iOS 13, *) {
- return .systemBackground
- }
- return .white
+ return .systemBackground
}
/// Tertiary background
static var tertiaryBackground: UIColor {
- if #available(iOS 13, *) {
- return .tertiarySystemBackground
- }
+ return .tertiarySystemBackground
+ }
- return .neutral(.shade10)
+ /// Quaternary background
+ static var quaternaryBackground: UIColor {
+ return .quaternarySystemFill
}
+ /// Tertiary system fill
+ static var tertiaryFill: UIColor {
+ return .tertiarySystemFill
+ }
+
/// Default text color: high contrast
static var text: UIColor {
- if #available(iOS 13, *) {
- return .label
- }
-
- return muriel(color: .text)
+ return .label
}
/// Secondary text color: less contrast
static var textSubtle: UIColor {
- if #available(iOS 13, *) {
- return .secondaryLabel
- }
-
- return muriel(color: .gray)
+ return .secondaryLabel
}
/// Very low contrast text
static var textTertiary: UIColor {
- if #available(iOS 13, *) {
- return .tertiaryLabel
- }
-
- return UIColor.neutral(.shade20)
+ return .tertiaryLabel
}
/// Very, very low contrast text
static var textQuaternary: UIColor {
- if #available(iOS 13, *) {
- return .quaternaryLabel
- }
-
- return UIColor.neutral(.shade10)
+ return .quaternaryLabel
}
static var textInverted = UIColor(light: .white, dark: .gray(.shade100))
static var textPlaceholder: UIColor {
- if #available(iOS 13, *) {
- return .tertiaryLabel
- }
-
- return neutral(.shade30)
+ return .tertiaryLabel
}
static var placeholderElement: UIColor {
- if #available(iOS 13, *) {
- return UIColor(light: .systemGray5, dark: .systemGray4)
- }
-
- return .gray(.shade10)
+ return UIColor(light: .systemGray5, dark: .systemGray4)
}
+
static var placeholderElementFaded: UIColor {
- if #available(iOS 13, *) {
- return UIColor(light: .systemGray6, dark: .systemGray5)
- }
+ return UIColor(light: .systemGray6, dark: .systemGray5)
+ }
+
+ // MARK: - Search Fields
- return .gray(.shade5)
+ static var searchFieldPlaceholderText: UIColor {
+ return .secondaryLabel
}
- /// Muriel/iOS navigation color
- static var appBar = UIColor(light: .brand, dark: .gray(.shade100))
+ static var searchFieldIcons: UIColor {
+ return .secondaryLabel
+ }
// MARK: - Table Views
static var divider: UIColor {
- if #available(iOS 13, *) {
- return .separator
- }
+ return .separator
+ }
- return muriel(color: .divider)
+ static var primaryButtonBorder: UIColor {
+ return .opaqueSeparator
}
/// WP color for table foregrounds (cells, etc)
static var listForeground: UIColor {
- if #available(iOS 13, *) {
- return .secondarySystemGroupedBackground
- }
-
- return .white
+ return .secondarySystemGroupedBackground
}
static var listForegroundUnread: UIColor {
- if #available(iOS 13, *) {
- return .tertiarySystemGroupedBackground
- }
-
- return .primary(.shade0)
+ return .tertiarySystemGroupedBackground
}
static var listBackground: UIColor {
- if #available(iOS 13, *) {
- return .systemGroupedBackground
- }
+ return .systemGroupedBackground
+ }
+
+ static var ungroupedListBackground: UIColor {
+ return .systemBackground
+ }
- return muriel(color: .gray, .shade0)
+ static var ungroupedListUnread: UIColor {
+ return UIColor(light: .primary(.shade0), dark: muriel(color: .gray, .shade80))
}
/// For icons that are present in a table view, or similar list
static var listIcon: UIColor {
- if #available(iOS 13, *) {
- return .secondaryLabel
- }
-
- return .neutral(.shade20)
+ return .secondaryLabel
}
/// For small icons, such as the badges on notification gravatars
static var listSmallIcon: UIColor {
- if #available(iOS 13, *) {
- return .systemGray
- }
+ return .systemGray
+ }
- return UIColor.neutral(.shade20)
+ static var buttonIcon: UIColor {
+ return .systemGray2
}
- static var filterBarBackground: UIColor {
- if #available(iOS 13, *) {
- return UIColor(light: white, dark: .gray(.shade100))
- }
+ /// For icons that are present in a toolbar or similar view
+ static var toolbarInactive: UIColor {
+ return .secondaryLabel
+ }
- return white
+ static var barButtonItemTitle: UIColor {
+ return UIColor(light: UIColor.primary(.shade50), dark: UIColor.primary(.shade30))
}
- static var filterBarSelected: UIColor {
- if #available(iOS 13, *) {
- return UIColor(light: .primary, dark: .label)
- }
+// MARK: - WP Fancy Buttons
+ static var primaryButtonBackground = primary
+ static var primaryButtonDownBackground = muriel(color: .primary, .shade80)
- return .primary
+ static var secondaryButtonBackground: UIColor {
+ return UIColor(light: .white, dark: .systemGray5)
}
- /// For icons that are present in a toolbar or similar view
- static var toolbarInactive: UIColor {
- if #available(iOS 13, *) {
- return .secondaryLabel
- }
-
- return .neutral(.shade30)
+ static var secondaryButtonBorder: UIColor {
+ return .systemGray3
}
- /// Note: these values are intended to match the iOS defaults
- static var tabUnselected: UIColor = UIColor(light: UIColor(hexString: "999999"), dark: UIColor(hexString: "757575"))
+ static var secondaryButtonDownBackground: UIColor {
+ return .systemGray3
+ }
-// MARK: - WP Fancy Buttons
- static var primaryButtonBackground = accent
- static var primaryButtonDownBackground = muriel(color: .accent, .shade80)
+ static var secondaryButtonDownBorder: UIColor {
+ return secondaryButtonBorder
+ }
- static var secondaryButtonBackground: UIColor {
- if #available(iOS 13, *) {
- return UIColor(light: .white, dark: .systemGray5)
- }
+ static var authSecondaryButtonBackground: UIColor {
+ return UIColor(light: .white, dark: .black)
+ }
- return .white
+ static var authButtonViewBackground: UIColor {
+ return UIColor(light: .white, dark: .black)
}
- static var secondaryButtonBorder: UIColor {
- if #available(iOS 13, *) {
- return .systemGray3
- }
+ // MARK: - Quick Action Buttons
- return .neutral(.shade20)
+ static var quickActionButtonBackground: UIColor {
+ .clear
}
- static var secondaryButtonDownBackground: UIColor {
+ static var quickActionButtonBorder: UIColor {
+ .systemGray3
+ }
- if #available(iOS 13, *) {
- return .systemGray3
- }
+ static var quickActionSelectedBackground: UIColor {
+ UIColor(light: .black, dark: .white).withAlphaComponent(0.17)
+ }
+
+ // MARK: - Others
- return .neutral(.shade20)
+ static var preformattedBackground: UIColor {
+ return .systemGray6
}
- static var secondaryButtonDownBorder: UIColor {
- return secondaryButtonBorder
+ static var prologueBackground: UIColor {
+ return UIColor(light: muriel(color: MurielColor(name: .blue, shade: .shade0)), dark: .systemBackground)
}
}
+@objc
extension UIColor {
// A way to create dynamic colors that's compatible with iOS 11 & 12
+ @objc
convenience init(light: UIColor, dark: UIColor) {
- if #available(iOS 13, *) {
- self.init { traitCollection in
- if traitCollection.userInterfaceStyle == .dark {
- return dark
- } else {
- return light
- }
+ self.init { traitCollection in
+ if traitCollection.userInterfaceStyle == .dark {
+ return dark
+ } else {
+ return light
}
- } else {
- // in older versions of iOS, we assume light mode
- self.init(color: light)
}
}
@@ -334,9 +325,17 @@ extension UIColor {
extension UIColor {
func color(for trait: UITraitCollection?) -> UIColor {
- if #available(iOS 13, *), let trait = trait {
+ if let trait = trait {
return resolvedColor(with: trait)
}
return self
}
+
+ func lightVariant() -> UIColor {
+ return color(for: UITraitCollection(userInterfaceStyle: .light))
+ }
+
+ func darkVariant() -> UIColor {
+ return color(for: UITraitCollection(userInterfaceStyle: .dark))
+ }
}
diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColorsObjC.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColorsObjC.swift
index 4f91aedf2315..770acee2a39b 100644
--- a/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColorsObjC.swift
+++ b/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColorsObjC.swift
@@ -16,6 +16,11 @@
return .primaryDark
}
+ @available(swift, obsoleted: 1.0)
+ static func murielEditorPrimary() -> UIColor {
+ return .editorPrimary
+ }
+
@available(swift, obsoleted: 1.0)
static func murielNeutral() -> UIColor {
return .neutral
@@ -130,4 +135,14 @@
static func murielListIcon() -> UIColor {
return .listIcon
}
+
+ @available(swift, obsoleted: 1.0)
+ static func murielAppBarText() -> UIColor {
+ return .appBarText
+ }
+
+ @available(swift, obsoleted: 1.0)
+ static func murielAppBarBackground() -> UIColor {
+ return .appBarBackground
+ }
}
diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+Notice.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+Notice.swift
new file mode 100644
index 000000000000..7cf1eaed3dab
--- /dev/null
+++ b/WordPress/Classes/Extensions/Colors and Styles/UIColor+Notice.swift
@@ -0,0 +1,26 @@
+extension UIColor {
+
+ static var invertedSystem5: UIColor {
+ return UIColor(light: UIColor.systemGray5.color(for: UITraitCollection(userInterfaceStyle: .dark)),
+ dark: UIColor.systemGray5.color(for: UITraitCollection(userInterfaceStyle: .light)))
+ }
+
+ static var invertedLabel: UIColor {
+ return UIColor(light: UIColor.label.color(for: UITraitCollection(userInterfaceStyle: .dark)),
+ dark: UIColor.label.color(for: UITraitCollection(userInterfaceStyle: .light)))
+ }
+
+ static var invertedSecondaryLabel: UIColor {
+ return UIColor(light: UIColor.secondaryLabel.color(for: UITraitCollection(userInterfaceStyle: .dark)),
+ dark: UIColor.secondaryLabel.color(for: UITraitCollection(userInterfaceStyle: .light)))
+ }
+
+ static var invertedLink: UIColor {
+ UIColor(light: .primary(.shade30), dark: .primary(.shade50))
+ }
+
+ static var invertedSeparator: UIColor {
+ return UIColor(light: UIColor.separator.color(for: UITraitCollection(userInterfaceStyle: .dark)),
+ dark: UIColor.separator.color(for: UITraitCollection(userInterfaceStyle: .light)))
+ }
+}
diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+WordPressColors.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+WordPressColors.swift
new file mode 100644
index 000000000000..d00d0c249430
--- /dev/null
+++ b/WordPress/Classes/Extensions/Colors and Styles/UIColor+WordPressColors.swift
@@ -0,0 +1,49 @@
+import UIKit
+
+// MARK: - UI elements
+extension UIColor {
+
+ /// Muriel/iOS navigation color
+ static var appBarBackground: UIColor {
+ UIColor(light: .white, dark: .gray(.shade100))
+ }
+
+ static var appBarTint: UIColor {
+ .primary
+ }
+
+ static var lightAppBarTint: UIColor {
+ return UIColor(light: .primary, dark: .white)
+ }
+
+ static var appBarText: UIColor {
+ .text
+ }
+
+ static var filterBarBackground: UIColor {
+ return UIColor(light: .white, dark: .gray(.shade100))
+ }
+
+ static var filterBarSelected: UIColor {
+ return UIColor(light: .primary, dark: .label)
+ }
+
+ static var filterBarSelectedText: UIColor {
+ return UIColor(light: .primary, dark: .label)
+ }
+
+ static var tabSelected: UIColor {
+ return .primary
+ }
+
+ /// Note: these values are intended to match the iOS defaults
+ static var tabUnselected: UIColor = UIColor(light: UIColor(hexString: "999999"), dark: UIColor(hexString: "757575"))
+
+ static var statsPrimaryHighlight: UIColor {
+ return UIColor(light: .accent(.shade30), dark: .accent(.shade60))
+ }
+
+ static var statsSecondaryHighlight: UIColor {
+ return UIColor(light: .accent(.shade60), dark: .accent(.shade30))
+ }
+}
diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift
index 5df6f4a8c754..b580087879c3 100644
--- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift
+++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift
@@ -2,17 +2,19 @@ import Foundation
import WordPressShared
extension WPStyleGuide {
- // MARK: - styles used before Muriel colors are enabled
- public class func navigationBarBackgroundImage() -> UIImage {
- return UIImage(color: WPStyleGuide.wordPressBlue())
+ @objc
+ public class var preferredStatusBarStyle: UIStatusBarStyle {
+ .default
}
- public class func navigationBarBarStyle() -> UIBarStyle {
- return .black
+ @objc
+ public class var navigationBarStandardFont: UIFont {
+ return AppStyleGuide.navigationBarStandardFont
}
- public class func navigationBarShadowImage() -> UIImage {
- return UIImage(color: UIColor(fromHex: 0x007eb1))
+ @objc
+ public class var navigationBarLargeFont: UIFont {
+ return AppStyleGuide.navigationBarLargeFont
}
class func configureDefaultTint() {
@@ -23,45 +25,62 @@ extension WPStyleGuide {
class func configureNavigationAppearance() {
let navigationAppearance = UINavigationBar.appearance()
navigationAppearance.isTranslucent = false
- navigationAppearance.tintColor = .white
- navigationAppearance.barTintColor = .appBar
- navigationAppearance.barStyle = .black
+ navigationAppearance.tintColor = .appBarTint
+ navigationAppearance.barTintColor = .appBarBackground
- if #available(iOS 13.0, *) {
- // Required to fix detail navigation controller appearance due to https://stackoverflow.com/q/56615513
- let appearance = UINavigationBarAppearance()
- appearance.configureWithOpaqueBackground()
- appearance.backgroundColor = .appBar
- appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
- navigationAppearance.standardAppearance = appearance
- navigationAppearance.scrollEdgeAppearance = navigationAppearance.standardAppearance
- }
+ var textAttributes: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.appBarText]
+ let largeTitleTextAttributes: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.navigationBarLargeFont]
+
+ textAttributes[.font] = WPStyleGuide.navigationBarStandardFont
+
+ navigationAppearance.titleTextAttributes = textAttributes
+ navigationAppearance.largeTitleTextAttributes = largeTitleTextAttributes
+
+ // Required to fix detail navigation controller appearance due to https://stackoverflow.com/q/56615513
+ let appearance = UINavigationBarAppearance()
+ appearance.configureWithOpaqueBackground()
+ appearance.backgroundColor = .appBarBackground
+ appearance.titleTextAttributes = textAttributes
+ appearance.largeTitleTextAttributes = largeTitleTextAttributes
+ appearance.shadowColor = .separator
+
+ let scrollEdgeAppearance = appearance.copy()
+ scrollEdgeAppearance.shadowColor = .clear
+ navigationAppearance.scrollEdgeAppearance = scrollEdgeAppearance
+
+ navigationAppearance.standardAppearance = appearance
+ navigationAppearance.compactAppearance = appearance
let buttonBarAppearance = UIBarButtonItem.appearance()
- buttonBarAppearance.tintColor = .white
- buttonBarAppearance.setTitleTextAttributes([NSAttributedString.Key.font: WPFontManager.systemRegularFont(ofSize: 17.0),
- NSAttributedString.Key.foregroundColor: UIColor.white],
- for: .normal)
- buttonBarAppearance.setTitleTextAttributes([NSAttributedString.Key.font: WPFontManager.systemRegularFont(ofSize: 17.0),
- NSAttributedString.Key.foregroundColor: UIColor.white.withAlphaComponent(0.25)],
- for: .disabled)
+ buttonBarAppearance.tintColor = .appBarTint
+ }
+
+ /// Style `UITableView` in the app
+ class func configureTableViewAppearance() {
+ if #available(iOS 15.0, *) {
+ UITableView.appearance().sectionHeaderTopPadding = 0
+ }
}
/// Style the tab bar using Muriel colors
class func configureTabBarAppearance() {
- UITabBar.appearance().tintColor = .primary
+ UITabBar.appearance().tintColor = .tabSelected
UITabBar.appearance().unselectedItemTintColor = .tabUnselected
+
+ if #available(iOS 15.0, *) {
+ let appearance = UITabBarAppearance()
+ appearance.configureWithOpaqueBackground()
+ appearance.backgroundColor = .systemBackground
+
+ UITabBar.appearance().standardAppearance = appearance
+ UITabBar.appearance().scrollEdgeAppearance = appearance
+ }
}
/// Style the `LightNavigationController` UINavigationBar and BarButtonItems
class func configureLightNavigationBarAppearance() {
let separatorColor: UIColor
-
- if #available(iOS 13.0, *) {
- separatorColor = .systemGray4
- } else {
- separatorColor = .lightGray
- }
+ separatorColor = .systemGray4
let navigationBarAppearanceProxy = UINavigationBar.appearance(whenContainedInInstancesOf: [LightNavigationController.self])
navigationBarAppearanceProxy.backgroundColor = .white // Only used on iOS 12 so doesn't need dark mode support
@@ -72,14 +91,12 @@ extension WPStyleGuide {
NSAttributedString.Key.foregroundColor: UIColor.text
]
- if #available(iOS 13.0, *) {
- let appearance = UINavigationBarAppearance()
- appearance.backgroundColor = .systemBackground
- appearance.shadowColor = separatorColor
- navigationBarAppearanceProxy.standardAppearance = appearance
- }
+ let appearance = UINavigationBarAppearance()
+ appearance.backgroundColor = .systemBackground
+ appearance.shadowColor = separatorColor
+ navigationBarAppearanceProxy.standardAppearance = appearance
- let tintColor = UIColor(light: .brand, dark: .white)
+ let tintColor = UIColor.lightAppBarTint
let buttonBarAppearance = UIBarButtonItem.appearance(whenContainedInInstancesOf: [LightNavigationController.self])
buttonBarAppearance.tintColor = tintColor
@@ -91,6 +108,17 @@ extension WPStyleGuide {
for: .disabled)
}
+
+ class func configureToolbarAppearance() {
+ let appearance = UIToolbarAppearance()
+ appearance.configureWithDefaultBackground()
+
+ UIToolbar.appearance().standardAppearance = appearance
+
+ if #available(iOS 15.0, *) {
+ UIToolbar.appearance().scrollEdgeAppearance = appearance
+ }
+ }
}
@@ -100,12 +128,14 @@ extension WPStyleGuide {
configureTableViewColors(view: view)
configureTableViewColors(tableView: tableView)
}
+
class func configureTableViewColors(view: UIView?) {
guard let view = view else {
return
}
view.backgroundColor = .basicBackground
}
+
class func configureTableViewColors(tableView: UITableView?) {
guard let tableView = tableView else {
return
diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Aztec.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Aztec.swift
index 483bb674a65d..f8c77aae9f21 100644
--- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Aztec.swift
+++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Aztec.swift
@@ -26,8 +26,7 @@ extension WPStyleGuide {
}
static func configureBetaButton(_ button: UIButton) {
- let helpImage = Gridicon.iconOfType(.helpOutline)
- button.setImage(helpImage, for: .normal)
+ button.setImage(.gridicon(.helpOutline), for: .normal)
button.tintColor = .neutral(.shade20)
let edgeInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0)
diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+FilterTabBar.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+FilterTabBar.swift
index aa538415eb19..07130d415312 100644
--- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+FilterTabBar.swift
+++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+FilterTabBar.swift
@@ -4,6 +4,7 @@ extension WPStyleGuide {
@objc class func configureFilterTabBar(_ filterTabBar: FilterTabBar) {
filterTabBar.backgroundColor = .filterBarBackground
filterTabBar.tintColor = .filterBarSelected
+ filterTabBar.selectedTitleColor = .filterBarSelectedText
filterTabBar.deselectedTabColor = .textSubtle
filterTabBar.dividerColor = .neutral(.shade10)
}
diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Jetpack.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Jetpack.swift
new file mode 100644
index 000000000000..c8c11fce087f
--- /dev/null
+++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Jetpack.swift
@@ -0,0 +1,31 @@
+import Foundation
+import WordPressShared
+
+extension WPStyleGuide {
+
+ enum Jetpack {
+
+ // MARK: - Style Methods
+
+ static func highlightString(_ substring: String, inString: String) -> NSAttributedString {
+ let attributedString = NSMutableAttributedString(string: inString)
+
+ guard let subStringRange = inString.nsRange(of: substring) else {
+ return attributedString
+ }
+
+ attributedString.addAttributes([
+ .foregroundColor: substringHighlightTextColor,
+ .font: substringHighlightFont
+ ], range: subStringRange)
+
+ return attributedString
+ }
+
+ // MARK: - Style Values
+
+ static let substringHighlightTextColor = UIColor.primary
+ static let substringHighlightFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold)
+ }
+
+}
diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Search.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Search.swift
index 2aec378d4a45..ebad5bb1c5a6 100644
--- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Search.swift
+++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Search.swift
@@ -1,20 +1,25 @@
import Foundation
import WordPressShared
+import UIKit
extension WPStyleGuide {
- @objc public class func configureSearchBar(_ searchBar: UISearchBar) {
+ fileprivate static let barTintColor: UIColor = .neutral(.shade10)
+
+ public class func configureSearchBar(_ searchBar: UISearchBar, backgroundColor: UIColor, returnKeyType: UIReturnKeyType) {
searchBar.accessibilityIdentifier = "Search"
searchBar.autocapitalizationType = .none
searchBar.autocorrectionType = .no
- searchBar.isTranslucent = false
- searchBar.barTintColor = .neutral(.shade10)
- searchBar.layer.borderColor = UIColor.neutral(.shade10).cgColor
+ searchBar.isTranslucent = true
+ searchBar.backgroundImage = UIImage()
+ searchBar.backgroundColor = backgroundColor
searchBar.layer.borderWidth = 1.0
- searchBar.returnKeyType = .done
- if #available(iOS 13.0, *) {
- searchBar.searchTextField.backgroundColor = .basicBackground
- }
+ searchBar.returnKeyType = returnKeyType
+ }
+
+ /// configures a search bar with a default `.appBackground` color and a `.done` return key
+ @objc public class func configureSearchBar(_ searchBar: UISearchBar) {
+ configureSearchBar(searchBar, backgroundColor: .appBarBackground, returnKeyType: .done)
}
@objc public class func configureSearchBarAppearance() {
@@ -23,28 +28,38 @@ extension WPStyleGuide {
let barButtonItemAppearance = UIBarButtonItem.appearance(whenContainedInInstancesOf: [UISearchBar.self])
barButtonItemAppearance.tintColor = .neutral(.shade70)
+ let iconSizes = CGSize(width: 20, height: 20)
+
// We have to manually tint these images, as we want them
// a different color from the search bar's cursor (which uses `tintColor`)
- let cancelImage = UIImage(named: "icon-clear-searchfield")?.imageWithTintColor(.neutral(.shade30))
- let searchImage = UIImage(named: "icon-post-list-search")?.imageWithTintColor(.neutral(.shade30))
- UISearchBar.appearance().setImage(cancelImage, for: .clear, state: UIControl.State())
+ let clearImage = UIImage.gridicon(.crossCircle, size: iconSizes).withTintColor(.searchFieldIcons).withRenderingMode(.alwaysOriginal)
+ let searchImage = UIImage.gridicon(.search, size: iconSizes).withTintColor(.searchFieldIcons).withRenderingMode(.alwaysOriginal)
+ UISearchBar.appearance().setImage(clearImage, for: .clear, state: UIControl.State())
UISearchBar.appearance().setImage(searchImage, for: .search, state: UIControl.State())
}
@objc public class func configureSearchBarTextAppearance() {
// Cancel button
let barButtonTitleAttributes: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.fixedFont(for: .headline),
- .foregroundColor: UIColor.neutral(.shade70)]
+ .foregroundColor: UIColor.neutral(.shade70)]
let barButtonItemAppearance = UIBarButtonItem.appearance(whenContainedInInstancesOf: [UISearchBar.self])
barButtonItemAppearance.setTitleTextAttributes(barButtonTitleAttributes, for: UIControl.State())
// Text field
- UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).defaultTextAttributes =
- (WPStyleGuide.defaultSearchBarTextAttributesSwifted(.neutral(.shade70)))
let placeholderText = NSLocalizedString("Search", comment: "Placeholder text for the search bar")
let attributedPlaceholderText = NSAttributedString(string: placeholderText,
- attributes: WPStyleGuide.defaultSearchBarTextAttributesSwifted(.neutral(.shade30)))
+ attributes: WPStyleGuide.defaultSearchBarTextAttributesSwifted(.searchFieldPlaceholderText))
UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).attributedPlaceholder =
attributedPlaceholderText
}
}
+
+extension UISearchBar {
+ // Per Apple's documentation (https://developer.apple.com/documentation/xcode/supporting_dark_mode_in_your_interface),
+ // `cgColor` objects do not adapt to appearance changes (i.e. toggling light/dark mode).
+ // `tintColorDidChange` is called when the appearance changes, so re-set the border color when this occurs.
+ open override func tintColorDidChange() {
+ super.tintColorDidChange()
+ layer.borderColor = UIColor.appBarBackground.cgColor
+ }
+}
diff --git a/WordPress/Classes/Extensions/Comment+Interface.swift b/WordPress/Classes/Extensions/Comment+Interface.swift
new file mode 100644
index 000000000000..f06dac6dabb6
--- /dev/null
+++ b/WordPress/Classes/Extensions/Comment+Interface.swift
@@ -0,0 +1,79 @@
+/// Allows comment objects to be sectioned by relative date.
+///
+/// This implementation is copied from Notification+Interface.swift. It pains me having to copy paste code,
+/// but we should be able clean this up once `Comment` and the view controller displaying it is rewritten in Swift.
+/// i.e.: Introduce a protocol with default implementation. Protocol extension doesn't work with @objc!
+///
+extension Comment {
+ /// Returns a Section Identifier that can be sorted. Note that this string is not human
+ /// readable, and you should use the *descriptionForSectionIdentifier* method
+ /// as well!
+ ///
+ @objc func relativeDateSectionIdentifier() -> String? {
+ guard let dateCreated = dateCreated else {
+ return nil
+ }
+
+ // Normalize Dates: Time must not be considered. Just the raw dates
+ let fromDate = dateCreated.normalizedDate()
+ let toDate = Date().normalizedDate()
+
+ // Analyze the Delta-Components
+ let calendar = Calendar.current
+ let components = [.day, .weekOfYear, .month] as Set
+ let dateComponents = calendar.dateComponents(components, from: fromDate, to: toDate)
+ let identifier: Sections
+
+ // Months
+ if let month = dateComponents.month, month >= 1 {
+ identifier = .Months
+ // Weeks
+ } else if let week = dateComponents.weekOfYear, week >= 1 {
+ identifier = .Weeks
+ // Days
+ } else if let day = dateComponents.day, day > 1 {
+ identifier = .Days
+ } else if let day = dateComponents.day, day == 1 {
+ identifier = .Yesterday
+ } else {
+ identifier = .Today
+ }
+
+ return identifier.rawValue
+ }
+
+ /// Translates a relative date section identifier into a human-readable string.
+ ///
+ @objc static func descriptionForSectionIdentifier(_ identifier: String) -> String {
+ guard let section = Sections(rawValue: identifier) else {
+ return String()
+ }
+
+ return section.description
+ }
+
+ // MARK: - Private Helpers
+
+ private enum Sections: String {
+ case Months = "0"
+ case Weeks = "2"
+ case Days = "4"
+ case Yesterday = "5"
+ case Today = "6"
+
+ var description: String {
+ switch self {
+ case .Months:
+ return NSLocalizedString("Older than a Month", comment: "Comments Months Section Header")
+ case .Weeks:
+ return NSLocalizedString("Older than a Week", comment: "Comments Weeks Section Header")
+ case .Days:
+ return NSLocalizedString("Older than 2 days", comment: "Comments +2 Days Section Header")
+ case .Yesterday:
+ return NSLocalizedString("Yesterday", comment: "Comments Yesterday Section Header")
+ case .Today:
+ return NSLocalizedString("Today", comment: "Comments Today Section Header")
+ }
+ }
+ }
+}
diff --git a/WordPress/Classes/Extensions/Font/UIFont+Fitting.swift b/WordPress/Classes/Extensions/Font/UIFont+Fitting.swift
new file mode 100644
index 000000000000..866638839931
--- /dev/null
+++ b/WordPress/Classes/Extensions/Font/UIFont+Fitting.swift
@@ -0,0 +1,48 @@
+// MIT License
+//
+// Copyright (c) 2019 Jonathan Cardasis
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+// Based on FontFit: https://github.com/joncardasis/FontFit
+
+public extension UIFont {
+ /**
+ Provides the largest font which fits the text in the given bounds.
+ */
+ static func fontFittingText(_ text: String, in bounds: CGSize, fontDescriptor: UIFontDescriptor) -> UIFont? {
+ let properBounds = CGRect(origin: .zero, size: bounds)
+ let largestFontSize = Int(bounds.height)
+ let constrainingBounds = CGSize(width: properBounds.width, height: CGFloat.infinity)
+
+ let bestFittingFontSize: Int? = (1...largestFontSize).reversed().first(where: { fontSize in
+ let font = UIFont(descriptor: fontDescriptor, size: CGFloat(fontSize))
+ let currentFrame = text.boundingRect(with: constrainingBounds, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font], context: nil)
+
+ if properBounds.contains(currentFrame) {
+ return true
+ }
+
+ return false
+ })
+
+ guard let fontSize = bestFittingFontSize else { return nil }
+ return UIFont(descriptor: fontDescriptor, size: CGFloat(fontSize))
+ }
+}
diff --git a/WordPress/Classes/Extensions/Font/UIFont+Weight.swift b/WordPress/Classes/Extensions/Font/UIFont+Weight.swift
new file mode 100644
index 000000000000..a8df631c9e16
--- /dev/null
+++ b/WordPress/Classes/Extensions/Font/UIFont+Weight.swift
@@ -0,0 +1,30 @@
+extension UIFont {
+ /// Returns a UIFont instance with the italic trait applied.
+ func italic() -> UIFont {
+ return withSymbolicTraits(.traitItalic)
+ }
+
+ /// Returns a UIFont instance with the bold trait applied.
+ func bold() -> UIFont {
+ return withWeight(.bold)
+ }
+
+ /// Returns a UIFont instance with the semibold trait applied.
+ func semibold() -> UIFont {
+ return withWeight(.semibold)
+ }
+
+ private func withSymbolicTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
+ guard let descriptor = fontDescriptor.withSymbolicTraits(traits) else {
+ return self
+ }
+
+ return UIFont(descriptor: descriptor, size: 0)
+ }
+
+ private func withWeight(_ weight: UIFont.Weight) -> UIFont {
+ let descriptor = fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: weight]])
+
+ return UIFont(descriptor: descriptor, size: 0)
+ }
+}
diff --git a/WordPress/Classes/Extensions/Interpolation.swift b/WordPress/Classes/Extensions/Interpolation.swift
new file mode 100644
index 000000000000..ae019dca6a7c
--- /dev/null
+++ b/WordPress/Classes/Extensions/Interpolation.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+extension CGFloat {
+ static func interpolated(from fromValue: CGFloat, to toValue: CGFloat, progress: CGFloat) -> CGFloat {
+ return fromValue.interpolated(to: toValue, with: progress)
+ }
+
+ /// Interpolates a CGFloat
+ /// - Parameters:
+ /// - toValue: The to value
+ /// - progress: a number between 0.0 and 1.0
+ /// - Returns: a new CGFloat value between 2 numbers using the progress
+ func interpolated(to toValue: CGFloat, with progress: CGFloat) -> CGFloat {
+ return (1 - progress) * self + progress * toValue
+ }
+}
+
+extension UIColor {
+ struct RGBAComponents {
+ var red: CGFloat = 0
+ var green: CGFloat = 0
+ var blue: CGFloat = 0
+ var alpha: CGFloat = 0
+
+ func interpolated(to toColor: RGBAComponents, with progress: CGFloat) -> RGBAComponents {
+ return RGBAComponents(red: red.interpolated(to: toColor.red, with: progress),
+ green: green.interpolated(to: toColor.green, with: progress),
+ blue: blue.interpolated(to: toColor.blue, with: progress),
+ alpha: alpha.interpolated(to: toColor.alpha, with: progress))
+ }
+ }
+
+ /// Returns the RGBA components for a color
+ var rgbaComponents: RGBAComponents {
+ var red: CGFloat = 0
+ var green: CGFloat = 0
+ var blue: CGFloat = 0
+ var alpha: CGFloat = 0
+
+ getRed(&red, green: &green, blue: &blue, alpha: &alpha)
+
+ return RGBAComponents(red: red, green: green, blue: blue, alpha: alpha)
+ }
+
+ /// Interpolates between the fromColor and toColor using the given progress
+ /// - Parameters:
+ /// - fromColor: The start color
+ /// - toColor: The start color
+ /// - progress: A value between 0.0 and 1.0
+ /// - Returns: An
+ static func interpolate(from fromColor: UIColor, to toColor: UIColor, with progress: CGFloat) -> UIColor {
+
+ if fromColor == toColor {
+ return fromColor
+ }
+
+ let components = fromColor.rgbaComponents.interpolated(to: toColor.rgbaComponents, with: progress)
+
+ return UIColor(red: components.red,
+ green: components.green,
+ blue: components.blue,
+ alpha: components.alpha)
+ }
+}
diff --git a/WordPress/Classes/Extensions/JSONDecoderExtension.swift b/WordPress/Classes/Extensions/JSONDecoderExtension.swift
new file mode 100644
index 000000000000..0b3802ecf131
--- /dev/null
+++ b/WordPress/Classes/Extensions/JSONDecoderExtension.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+extension JSONDecoder.DateDecodingStrategy {
+
+ enum DateFormat: String, CaseIterable {
+ case noTime = "yyyy-mm-dd"
+ case dateWithTime = "yyyy-MM-dd HH:mm:ss"
+ case iso8601 = "yyyy-MM-dd'T'HH:mm:ssZ"
+ case iso8601WithMilliseconds = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"
+
+ var formatter: DateFormatter {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = rawValue
+ return dateFormatter
+ }
+ }
+
+ static var supportMultipleDateFormats: JSONDecoder.DateDecodingStrategy {
+ return JSONDecoder.DateDecodingStrategy.custom({ (decoder) -> Date in
+ let container = try decoder.singleValueContainer()
+ let dateStr = try container.decode(String.self)
+
+ var date: Date?
+
+ for format in DateFormat.allCases {
+ date = format.formatter.date(from: dateStr)
+ if date != nil {
+ break
+ }
+ }
+
+ guard let calculatedDate = date else {
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Cannot decode date string \(dateStr)"
+ )
+ }
+
+ return calculatedDate
+ })
+ }
+}
diff --git a/WordPress/Classes/Extensions/Media+Blog.swift b/WordPress/Classes/Extensions/Media+Blog.swift
index 88d9197470c8..84fad141cfbe 100644
--- a/WordPress/Classes/Extensions/Media+Blog.swift
+++ b/WordPress/Classes/Extensions/Media+Blog.swift
@@ -59,4 +59,19 @@ extension Media {
return media
}
+
+ /// Returns a list of Media objects from a post, that should be autoUploaded on the next attempt.
+ ///
+ /// - Parameters:
+ /// - post: the post to look auto-uploadable media for.
+ /// - automatedRetry: whether the media to upload is the result of an automated retry.
+ ///
+ /// - Returns: the Media objects that should be autoUploaded.
+ ///
+ class func failedForUpload(in post: AbstractPost, automatedRetry: Bool) -> [Media] {
+ post.media.filter { media in
+ media.remoteStatus == .failed
+ && (!automatedRetry || media.autoUploadFailureCount.intValue < Media.maxAutoUploadFailureCount)
+ }
+ }
}
diff --git a/WordPress/Classes/Extensions/Media+Sync.swift b/WordPress/Classes/Extensions/Media+Sync.swift
new file mode 100644
index 000000000000..6fac636fcfa7
--- /dev/null
+++ b/WordPress/Classes/Extensions/Media+Sync.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+extension Media {
+
+ /// Returns a list of Media objects that should be uploaded for the given input parameters.
+ ///
+ /// - Parameters:
+ /// - automatedRetry: whether the media to upload is the result of an automated retry.
+ ///
+ /// - Returns: the Media objects that should be uploaded for the given input parameters.
+ ///
+ static func failedMediaForUpload(automatedRetry: Bool, in context: NSManagedObjectContext) -> [Media] {
+ let request = NSFetchRequest(entityName: Media.entityName())
+ let failedMediaPredicate = NSPredicate(format: "\(#keyPath(Media.remoteStatusNumber)) == %d", MediaRemoteStatus.failed.rawValue)
+
+ if automatedRetry {
+ let autoUploadFailureCountPredicate = NSPredicate(format: "\(#keyPath(Media.autoUploadFailureCount)) < %d", Media.maxAutoUploadFailureCount)
+
+ request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [failedMediaPredicate, autoUploadFailureCountPredicate])
+ } else {
+ request.predicate = failedMediaPredicate
+ }
+
+ let media = (try? context.fetch(request)) ?? []
+
+ return media
+ }
+
+ /// This method checks the status of all media objects and updates them to the correct status if needed.
+ /// The main cause of wrong status is the app being killed while uploads of media are happening.
+ ///
+ /// - Parameters:
+ /// - onCompletion: block to invoke when status update is finished.
+ /// - onError: block to invoke if any error occurs while the update is being made.
+ ///
+ static func refreshMediaStatus(using coreDataStack: CoreDataStackSwift, onCompletion: (() -> Void)? = nil, onError: ((Error) -> Void)? = nil) {
+ coreDataStack.performAndSave({ context in
+ let fetch = NSFetchRequest(entityName: Media.classNameWithoutNamespaces())
+ let pushingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.pushing.rawValue))
+ let processingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.processing.rawValue))
+ let errorPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.failed.rawValue))
+ fetch.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [pushingPredicate, processingPredicate, errorPredicate])
+ let mediaPushing = try context.fetch(fetch)
+ for media in mediaPushing {
+ // If file were in the middle of being pushed or being processed they now are failed.
+ if media.remoteStatus == .pushing || media.remoteStatus == .processing {
+ media.remoteStatus = .failed
+ }
+ // If they failed to upload themselfs because no local copy exists then we need to delete this media object
+ // This scenario can happen when media objects were created based on an asset that failed to import to the WordPress App.
+ // For example a PHAsset that is stored on the iCloud storage and because of the network connection failed the import process.
+ if media.remoteStatus == .failed,
+ let error = media.error as NSError?, error.domain == MediaServiceErrorDomain && error.code == MediaServiceError.fileDoesNotExist.rawValue {
+ context.delete(media)
+ }
+ }
+ }, completion: { result in
+ switch result {
+ case .success:
+ onCompletion?()
+ case let .failure(error):
+ DDLogError("Error while attempting to clean local media: \(error.localizedDescription)")
+ onError?(error)
+ }
+ }, on: .main)
+ }
+
+}
diff --git a/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift b/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift
index f11c2739b856..9e6b2c056752 100644
--- a/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift
+++ b/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift
@@ -1,4 +1,5 @@
-import Foundation
+import UIKit
+import MobileCoreServices
@objc
public extension NSAttributedString {
@@ -22,9 +23,28 @@ public extension NSAttributedString {
var rangeDelta = 0
for (value, image) in unwrappedEmbeds {
- let imageAttachment = NSTextAttachment()
- imageAttachment.bounds = CGRect(origin: CGPoint.zero, size: image.size)
- imageAttachment.image = image
+ let imageAttachment = NSTextAttachment()
+ let gifType = kUTTypeGIF as String
+ var displayAnimatedGifs = false
+
+ // Check to see if the animated gif view provider is registered
+ if #available(iOS 15.0, *) {
+ displayAnimatedGifs = NSTextAttachment.textAttachmentViewProviderClass(forFileType: gifType) == AnimatedGifAttachmentViewProvider.self
+ }
+
+ // When displaying an animated gif pass the gif data instead of the image
+ if
+ displayAnimatedGifs,
+ let animatedImage = image as? AnimatedImageWrapper,
+ animatedImage.gifData != nil
+ {
+ imageAttachment.contents = animatedImage.gifData
+ imageAttachment.fileType = gifType
+ imageAttachment.bounds = CGRect(origin: CGPoint.zero, size: animatedImage.targetSize ?? image.size)
+ } else {
+ imageAttachment.image = image
+ imageAttachment.bounds = CGRect(origin: CGPoint.zero, size: image.size)
+ }
// Each embed is expected to add 1 char to the string. Compensate for that
let attachmentString = NSAttributedString(attachment: imageAttachment)
diff --git a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift
index fcff310b7952..5987b85fc10a 100644
--- a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift
+++ b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift
@@ -115,7 +115,7 @@ public enum HTMLAttributeType: String {
}
}
-private extension UIFont {
+public extension UIFont {
var isBold: Bool {
return fontDescriptor.symbolicTraits.contains(.traitBold)
}
diff --git a/WordPress/Classes/Extensions/NSCalendar+Helpers.swift b/WordPress/Classes/Extensions/NSCalendar+Helpers.swift
index 33198832235f..4d3f2620a80e 100644
--- a/WordPress/Classes/Extensions/NSCalendar+Helpers.swift
+++ b/WordPress/Classes/Extensions/NSCalendar+Helpers.swift
@@ -9,4 +9,35 @@ extension Calendar {
let components = dateComponents([.day], from: fromDate, to: toDate)
return components.day!
}
+
+ /// Converts a localized weekday index (where 0 can be either Sunday or Monday depending on the locale settings)
+ /// into an unlocalized weekday index (where 0 is always Sunday).
+ ///
+ /// - Parameters:
+ /// - localizedWeekdayIndex: a localized weekday index representing the desired day of
+ /// the week. 0 could either be Sunday or Monday depending on the `Calendar`'s locale settings.
+ ///
+ /// - Returns: an index where 0 is always Sunday. This index can be used with methods such as `Calendar.weekdaySymbol`
+ /// to obtain the name of the day.
+ ///
+ public func unlocalizedWeekdayIndex(localizedWeekdayIndex: Int) -> Int {
+ return (localizedWeekdayIndex + firstWeekday - 1) % weekdaySymbols.count
+ }
+
+ /// Converts an unlocalized weekday index (where 0 is always Sunday)
+ /// into a localized weekday index (where 0 can be either Sunday or Monday depending on the locale settings).
+ ///
+ /// - Parameters:
+ /// - unlocalizedWeekdayIndex: an unlocalized weekday index representing the desired day of
+ /// the week. 0 is always Sunday.
+ ///
+ /// - Returns: an index where 0 can be either Sunday or Monday depending on locale settings.
+ ///
+ public func localizedWeekdayIndex(unlocalizedWeekdayIndex: Int) -> Int {
+ let firstZeroBasedWeekday = firstWeekday - 1
+
+ return unlocalizedWeekdayIndex >= firstZeroBasedWeekday
+ ? unlocalizedWeekdayIndex - firstZeroBasedWeekday
+ : unlocalizedWeekdayIndex + weekdaySymbols.count - firstZeroBasedWeekday
+ }
}
diff --git a/WordPress/Classes/Extensions/NSMutableAttributedString+ApplyAttributesToQuotes.swift b/WordPress/Classes/Extensions/NSMutableAttributedString+ApplyAttributesToQuotes.swift
new file mode 100644
index 000000000000..a005e1eb3dc9
--- /dev/null
+++ b/WordPress/Classes/Extensions/NSMutableAttributedString+ApplyAttributesToQuotes.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+extension NSMutableAttributedString {
+
+ /// Applies a collection of attributes to all of quoted substrings
+ ///
+ /// - Parameters:
+ /// - attributes: Collection of attributes to be applied on the matched strings
+ ///
+ public func applyAttributes(toQuotes attributes: [NSAttributedString.Key: Any]?) {
+ guard let attributes = attributes else {
+ return
+ }
+ let rawString = self.string
+ let scanner = Scanner(string: rawString)
+ let quotes = scanner.scanQuotedText()
+ quotes.forEach {
+ if let itemRange = rawString.range(of: $0) {
+ let range = NSRange(itemRange, in: rawString)
+ self.addAttributes(attributes, range: range)
+ }
+ }
+ }
+}
diff --git a/WordPress/Classes/Extensions/NoResultsViewController+FollowedSites.swift b/WordPress/Classes/Extensions/NoResultsViewController+FollowedSites.swift
new file mode 100644
index 000000000000..2800982dac7a
--- /dev/null
+++ b/WordPress/Classes/Extensions/NoResultsViewController+FollowedSites.swift
@@ -0,0 +1,33 @@
+import Foundation
+
+extension NoResultsViewController {
+ private struct Constants {
+ static let noFollowedSitesTitle = NSLocalizedString("No followed sites", comment: "Title for the no followed sites result screen")
+ static let noFollowedSitesSubtitle = NSLocalizedString("When you follow sites, you’ll see their content here.", comment: "Subtitle for the no followed sites result screen")
+ static let noFollowedSitesButtonTitle = NSLocalizedString("Discover Sites", comment: "Title for button on the no followed sites result screen")
+
+ static let noFollowedSitesImage = "wp-illustration-following-empty-results"
+ }
+
+ class func noFollowedSitesController(showActionButton showButton: Bool) -> NoResultsViewController {
+ let titleText = NSMutableAttributedString(string: Constants.noFollowedSitesTitle,
+ attributes: WPStyleGuide.noFollowedSitesErrorTitleAttributes())
+
+ let subtitleText = NSMutableAttributedString(string: Constants.noFollowedSitesSubtitle,
+ attributes: WPStyleGuide.noFollowedSitesErrorSubtitleAttributes())
+
+ let controller = NoResultsViewController.controller()
+
+ controller.configure(title: "",
+ attributedTitle: titleText,
+ buttonTitle: showButton ? Constants.noFollowedSitesButtonTitle : nil,
+ attributedSubtitle: subtitleText,
+ attributedSubtitleConfiguration: { (attributedText: NSAttributedString) -> NSAttributedString? in
+ return subtitleText },
+ image: Constants.noFollowedSitesImage)
+ controller.labelStackViewSpacing = 12
+ controller.labelButtonStackViewSpacing = 18
+
+ return controller
+ }
+}
diff --git a/WordPress/Classes/Extensions/Notifications/NotificationName+Names.swift b/WordPress/Classes/Extensions/Notifications/NotificationName+Names.swift
index 1a5812edbb63..01c13f207c1b 100644
--- a/WordPress/Classes/Extensions/Notifications/NotificationName+Names.swift
+++ b/WordPress/Classes/Extensions/Notifications/NotificationName+Names.swift
@@ -6,14 +6,9 @@ extension Foundation.Notification.Name {
static var reachabilityChanged: Foundation.NSNotification.Name {
return Foundation.Notification.Name("org.wordpress.reachability.changed")
}
-
- static var showAllSavedForLaterPosts: Foundation.NSNotification.Name {
- return Foundation.Notification.Name("org.wordpress.reader.savedforlaterposts.showall")
- }
}
@objc extension NSNotification {
- public static let ShowAllSavedForLaterPostsNotification = Foundation.Notification.Name.showAllSavedForLaterPosts
public static let ReachabilityChangedNotification = Foundation.Notification.Name.reachabilityChanged
}
diff --git a/WordPress/Classes/Extensions/Post+BloggingPrompts.swift b/WordPress/Classes/Extensions/Post+BloggingPrompts.swift
new file mode 100644
index 000000000000..4a522db6da48
--- /dev/null
+++ b/WordPress/Classes/Extensions/Post+BloggingPrompts.swift
@@ -0,0 +1,46 @@
+extension Post {
+
+ func prepareForPrompt(_ prompt: BloggingPrompt?) {
+ guard let prompt = prompt else {
+ return
+ }
+
+ content = promptContent(withPromptText: prompt.text)
+ bloggingPromptID = String(prompt.promptID)
+
+ if let currentTags = tags {
+ tags = "\(currentTags), \(Strings.promptTag)"
+ } else {
+ tags = Strings.promptTag
+ }
+
+ if FeatureFlag.bloggingPromptsEnhancements.enabled {
+ tags?.append(", \(Strings.promptTag)-\(prompt.promptID)")
+ }
+ }
+
+ private func promptContent(withPromptText promptText: String) -> String {
+ if FeatureFlag.bloggingPromptsEnhancements.enabled {
+ return pullquoteBlock(promptText: promptText) + Strings.emptyParagraphBlock
+ } else {
+ return pullquoteBlock(promptText: promptText)
+ }
+ }
+
+ private func pullquoteBlock(promptText: String) -> String {
+ return """
+
+
\(promptText)
+
+ """
+ }
+
+ private enum Strings {
+ static let promptTag = "dailyprompt"
+ static let emptyParagraphBlock = """
+
+
+
+ """
+ }
+}
diff --git a/WordPress/Classes/Extensions/Scanner+QuotedText.swift b/WordPress/Classes/Extensions/Scanner+QuotedText.swift
new file mode 100644
index 000000000000..d7230c45b9a9
--- /dev/null
+++ b/WordPress/Classes/Extensions/Scanner+QuotedText.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+extension Scanner {
+ public func scanQuotedText() -> [String] {
+ var allQuotedTextFound = [String]()
+ var textRead: String?
+ let quoteString = "\""
+ while self.isAtEnd == false {
+ _ = scanUpToString(quoteString) // scan up to quotation mark
+ _ = scanString(quoteString) // skip opening quotation mark
+ textRead = scanUpToString(quoteString) // read text up to next quotation mark
+ let closingMarkFound = scanString(quoteString) != nil // skip closing quotation mark
+
+ if let quotedTextFound = textRead, quotedTextFound.isEmpty == false, closingMarkFound {
+ allQuotedTextFound.append(quotedTextFound as String)
+ }
+ }
+
+ return allQuotedTextFound
+ }
+}
diff --git a/WordPress/Classes/Extensions/String+CondenseWhitespace.swift b/WordPress/Classes/Extensions/String+CondenseWhitespace.swift
new file mode 100644
index 000000000000..5cb2508d14d4
--- /dev/null
+++ b/WordPress/Classes/Extensions/String+CondenseWhitespace.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+extension String {
+ ///
+ /// Attempts to remove excessive whitespace in text by replacing multiple new lines with just 2.
+ /// This first trims whitespace and newlines from the ends
+ /// Then normalizes the newlines by replacing {Space}{Newline} with a single newline char
+ /// Then finally it looks for any newlines that are 3 or more and replaces them with 2 newlines.
+ ///
+ /// Example:
+ /// ```
+ /// This is the first line
+ ///
+ ///
+ ///
+ ///
+ /// This is the last line
+ /// ```
+ /// Turns into:
+ /// ```
+ /// This is the first line
+ ///
+ /// This is the last line
+ /// ```
+ ///
+ func condenseWhitespace() -> String {
+ return self.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
+ .replacingOccurrences(of: "\\s\n", with: "\n", options: .regularExpression, range: nil)
+ .replacingOccurrences(of: "[\n]{3,}", with: "\n\n", options: .regularExpression, range: nil)
+ }
+}
diff --git a/WordPress/Classes/Extensions/UIAlertController+Helpers.swift b/WordPress/Classes/Extensions/UIAlertController+Helpers.swift
index 313d16e1c035..4c2a295cbc69 100644
--- a/WordPress/Classes/Extensions/UIAlertController+Helpers.swift
+++ b/WordPress/Classes/Extensions/UIAlertController+Helpers.swift
@@ -7,15 +7,9 @@ import WordPressFlux
// This method is required because the presenter ViewController must be visible, and we've got several
// flows in which the VC that triggers the alert, might not be visible anymore.
//
- guard let rootViewController = UIApplication.shared.keyWindow?.rootViewController else {
- print("Error loading the rootViewController")
+ guard let leafViewController = UIApplication.shared.leafViewController else {
return
}
-
- var leafViewController = rootViewController
- while leafViewController.presentedViewController != nil && !leafViewController.presentedViewController!.isBeingDismissed {
- leafViewController = leafViewController.presentedViewController!
- }
popoverPresentationController?.sourceView = view
popoverPresentationController?.permittedArrowDirections = []
leafViewController.present(self, animated: true)
diff --git a/WordPress/Classes/Extensions/UIApplication+AppAvailability.swift b/WordPress/Classes/Extensions/UIApplication+AppAvailability.swift
new file mode 100644
index 000000000000..8efc0205f728
--- /dev/null
+++ b/WordPress/Classes/Extensions/UIApplication+AppAvailability.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+enum AppScheme: String {
+ case wordpress = "wordpress://"
+ case wordpressMigrationV1 = "wordpressmigration+v1://"
+ case jetpack = "jetpack://"
+}
+
+extension UIApplication {
+ func canOpen(app: AppScheme) -> Bool {
+ guard let url = URL(string: app.rawValue) else {
+ return false
+ }
+ return canOpenURL(url)
+ }
+}
diff --git a/WordPress/Classes/Extensions/UIApplication+Helpers.swift b/WordPress/Classes/Extensions/UIApplication+Helpers.swift
new file mode 100644
index 000000000000..3cc886fc67e4
--- /dev/null
+++ b/WordPress/Classes/Extensions/UIApplication+Helpers.swift
@@ -0,0 +1,12 @@
+import Foundation
+import UIKit
+
+extension UIApplication {
+ func openSettings() {
+ guard let url = URL(string: UIApplication.openSettingsURLString) else {
+ return
+ }
+
+ self.open(url)
+ }
+}
diff --git a/WordPress/Classes/Extensions/UIApplication+mainWindow.swift b/WordPress/Classes/Extensions/UIApplication+mainWindow.swift
new file mode 100644
index 000000000000..3097a37545e8
--- /dev/null
+++ b/WordPress/Classes/Extensions/UIApplication+mainWindow.swift
@@ -0,0 +1,26 @@
+import UIKit
+
+extension UIApplication {
+ @objc var mainWindow: UIWindow? {
+ return windows.filter {$0.isKeyWindow}.first
+ }
+
+ @objc var currentStatusBarFrame: CGRect {
+ return mainWindow?.windowScene?.statusBarManager?.statusBarFrame ?? CGRect.zero
+ }
+
+ @objc var currentStatusBarOrientation: UIInterfaceOrientation {
+ return mainWindow?.windowScene?.interfaceOrientation ?? .unknown
+ }
+
+ var leafViewController: UIViewController? {
+ guard let rootViewController = mainWindow?.rootViewController else {
+ return nil
+ }
+ var leafViewController = rootViewController
+ while leafViewController.presentedViewController != nil && !leafViewController.presentedViewController!.isBeingDismissed {
+ leafViewController = leafViewController.presentedViewController!
+ }
+ return leafViewController
+ }
+}
diff --git a/WordPress/Classes/Extensions/UIEdgeInsets.swift b/WordPress/Classes/Extensions/UIEdgeInsets.swift
index 672c9cabf71f..33df6842bb61 100644
--- a/WordPress/Classes/Extensions/UIEdgeInsets.swift
+++ b/WordPress/Classes/Extensions/UIEdgeInsets.swift
@@ -1,12 +1,25 @@
import UIKit
extension UIEdgeInsets {
+ var flippedForRightToLeft: UIEdgeInsets {
+ guard UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft else {
+ return self
+ }
+
+ return flippedForRightToLeftLayoutDirection()
+ }
+
func flippedForRightToLeftLayoutDirection() -> UIEdgeInsets {
return UIEdgeInsets(top: top, left: right, bottom: bottom, right: left)
}
+
+ init(allEdges: CGFloat) {
+ self.init(top: allEdges, left: allEdges, bottom: allEdges, right: allEdges)
+ }
}
extension UIButton {
+
@objc func flipInsetsForRightToLeftLayoutDirection() {
guard userInterfaceLayoutDirection() == .rightToLeft else {
return
@@ -15,6 +28,32 @@ extension UIButton {
imageEdgeInsets = imageEdgeInsets.flippedForRightToLeftLayoutDirection()
titleEdgeInsets = titleEdgeInsets.flippedForRightToLeftLayoutDirection()
}
+
+ func verticallyAlignImageAndText(padding: CGFloat = 5) {
+ guard let imageView = imageView,
+ let titleLabel = titleLabel else {
+ return
+ }
+
+ let imageSize = imageView.frame.size
+ let titleSize = titleLabel.frame.size
+ let totalHeight = imageSize.height + titleSize.height + padding
+
+ imageEdgeInsets = UIEdgeInsets(
+ top: -(totalHeight - imageSize.height),
+ left: 0,
+ bottom: 0,
+ right: -titleSize.width
+ )
+
+ titleEdgeInsets = UIEdgeInsets(
+ top: 0,
+ left: -imageSize.width,
+ bottom: -(totalHeight - titleSize.height),
+ right: 0
+ )
+ }
+
}
// Hack: Since UIEdgeInsets is a struct in ObjC, you can't have methods on it.
diff --git a/WordPress/Classes/Extensions/UIImageView+SiteIcon.swift b/WordPress/Classes/Extensions/UIImageView+SiteIcon.swift
index 9241fd9d5f03..e6b1f583f4f7 100644
--- a/WordPress/Classes/Extensions/UIImageView+SiteIcon.swift
+++ b/WordPress/Classes/Extensions/UIImageView+SiteIcon.swift
@@ -1,5 +1,8 @@
+import AlamofireImage
+import Alamofire
+import AutomatticTracks
import Foundation
-
+import Gridicons
/// UIImageView Helper Methods that allow us to download a SiteIcon, given a website's "Icon Path"
///
@@ -8,21 +11,14 @@ extension UIImageView {
/// Default Settings
///
struct SiteIconDefaults {
-
/// Default SiteIcon's Image Size, in points.
///
- static let imageSize = 40
-
- /// Default SiteIcon's Image Size, in pixels.
- ///
- static var imageSizeInPixels: Int {
- return imageSize * Int(UIScreen.main.scale)
- }
+ static let imageSize = CGSize(width: 40, height: 40)
}
/// Downloads the SiteIcon Image, hosted at the specified path. This method will attempt to optimize the URL, so that
- /// the download Image Size matches `SiteIconDefaults.imageSize`.
+ /// the download Image Size matches `imageSize`.
///
/// TODO: This is a convenience method. Nuke me once we're all swifted.
///
@@ -35,80 +31,145 @@ extension UIImageView {
/// Downloads the SiteIcon Image, hosted at the specified path. This method will attempt to optimize the URL, so that
- /// the download Image Size matches `SiteIconDefaults.imageSize`.
+ /// the download Image Size matches `imageSize`.
///
/// - Parameters:
/// - path: SiteIcon's url (string encoded) to be downloaded.
+ /// - imageSize: Request site icon in the specified image size.
/// - placeholderImage: Yes. It's the "place holder image", Sherlock.
///
@objc
- func downloadSiteIcon(at path: String, placeholderImage: UIImage?) {
- guard let siteIconURL = optimizedURL(for: path) else {
+ func downloadSiteIcon(
+ at path: String,
+ imageSize: CGSize = SiteIconDefaults.imageSize,
+ placeholderImage: UIImage?
+ ) {
+ guard let siteIconURL = optimizedURL(for: path, imageSize: imageSize) else {
image = placeholderImage
return
}
- downloadImage(from: siteIconURL, placeholderImage: placeholderImage)
+ logURLOptimization(from: path, to: siteIconURL)
+
+ let request = URLRequest(url: siteIconURL)
+ downloadSiteIcon(with: request, imageSize: imageSize, placeholderImage: placeholderImage)
+ }
+
+ /// Downloads a SiteIcon image, using a specified request.
+ ///
+ /// - Parameters:
+ /// - request: The request for the SiteIcon.
+ /// - imageSize: Request site icon in the specified image size.
+ /// - placeholderImage: Yes. It's the "place holder image".
+ ///
+ private func downloadSiteIcon(
+ with request: URLRequest,
+ imageSize expectedSize: CGSize = SiteIconDefaults.imageSize,
+ placeholderImage: UIImage?
+ ) {
+ af_setImage(withURLRequest: request, placeholderImage: placeholderImage, completion: { [weak self] dataResponse in
+ switch dataResponse.result {
+ case .success(let image):
+ guard let self = self else {
+ return
+ }
+
+ // In `MediaRequesAuthenticator.authenticatedRequestForPrivateAtomicSiteThroughPhoton` we're
+ // having to replace photon URLs for Atomic Private Sites, with a call to the Atomic Media Proxy
+ // endpoint. The downside of calling that endpoint is that it doesn't always return images of
+ // the requested size.
+ //
+ // The following lines of code ensure that we resize the image to the default Site Icon size, to
+ // ensure there is no UI breakage due to having larger images set here.
+ //
+ if image.size != expectedSize {
+ self.image = image.resizedImage(with: .scaleAspectFill, bounds: expectedSize, interpolationQuality: .default)
+ } else {
+ self.image = image
+ }
+
+ self.removePlaceholderBorder()
+ case .failure(let error):
+ if case .requestCancelled = (error as? AFIError) {
+ // Do not log intentionally cancelled requests as errors.
+ } else {
+ DDLogError(error.localizedDescription)
+ }
+ }
+ })
}
/// Downloads the SiteIcon Image, associated to a given Blog. This method will attempt to optimize the URL, so that
- /// the download Image Size matches `SiteIconDefaults.imageSize`.
+ /// the download Image Size matches `imageSize`.
///
/// - Parameters:
/// - blog: reference to the source blog
/// - placeholderImage: Yes. It's the "place holder image".
///
- @objc
- func downloadSiteIcon(for blog: Blog, placeholderImage: UIImage? = .siteIconPlaceholder) {
- guard let siteIconPath = blog.icon, let siteIconURL = optimizedURL(for: siteIconPath) else {
+ @objc func downloadSiteIcon(
+ for blog: Blog,
+ imageSize: CGSize = SiteIconDefaults.imageSize,
+ placeholderImage: UIImage? = .siteIconPlaceholder
+ ) {
+ guard let siteIconPath = blog.icon, let siteIconURL = optimizedURL(for: siteIconPath, imageSize: imageSize) else {
+
+ if blog.isWPForTeams() && placeholderImage == .siteIconPlaceholder {
+ image = UIImage.gridicon(.p2, size: imageSize)
+ return
+ }
+
image = placeholderImage
return
}
- let request: URLRequest
- if blog.isPrivate(), PrivateSiteURLProtocol.urlGoes(toWPComSite: siteIconURL) {
- request = PrivateSiteURLProtocol.requestForPrivateSite(from: siteIconURL)
- } else {
- request = URLRequest(url: siteIconURL)
+ logURLOptimization(from: siteIconPath, to: siteIconURL, for: blog)
+
+ let host = MediaHost(with: blog) { error in
+ // We'll log the error, so we know it's there, but we won't halt execution.
+ DDLogError(error.localizedDescription)
}
- downloadImage(usingRequest: request, placeholderImage: placeholderImage, success: { [weak self] (image) in
- self?.image = image
- self?.removePlaceholderBorder()
- }, failure: nil)
+ let mediaRequestAuthenticator = MediaRequestAuthenticator()
+ mediaRequestAuthenticator.authenticatedRequest(
+ for: siteIconURL,
+ from: host,
+ onComplete: { [weak self] request in
+ self?.downloadSiteIcon(with: request, imageSize: imageSize, placeholderImage: placeholderImage)
+ }) { error in
+ DDLogError(error.localizedDescription)
+ }
}
}
// MARK: - Private Methods
//
-private extension UIImageView {
-
+extension UIImageView {
/// Returns the Size Optimized URL for a given Path.
///
- func optimizedURL(for path: String) -> URL? {
+ func optimizedURL(for path: String, imageSize: CGSize = SiteIconDefaults.imageSize) -> URL? {
if isPhotonURL(path) || isDotcomURL(path) {
- return optimizedDotcomURL(from: path)
+ return optimizedDotcomURL(from: path, imageSize: imageSize)
}
if isBlavatarURL(path) {
- return optimizedBlavatarURL(from: path)
+ return optimizedBlavatarURL(from: path, imageSize: imageSize)
}
- return optimizedPhotonURL(from: path)
+ return optimizedPhotonURL(from: path, imageSize: imageSize)
}
// MARK: - Private Helpers
- /// Returns the download URL for a square icon with a size of `SiteIconDefaults.imageSizeInPixels`
+ /// Returns the download URL for a square icon with a size of `imageSize` in pixels.
///
/// - Parameter path: SiteIcon URL (string encoded).
///
- private func optimizedDotcomURL(from path: String) -> URL? {
- let size = SiteIconDefaults.imageSizeInPixels
- let query = String(format: "w=%d&h=%d", size, size)
+ private func optimizedDotcomURL(from path: String, imageSize: CGSize = SiteIconDefaults.imageSize) -> URL? {
+ let size = imageSize.toPixels()
+ let query = String(format: "w=%d&h=%d", Int(size.width), Int(size.height))
return parseURL(path: path, query: query)
}
@@ -118,9 +179,9 @@ private extension UIImageView {
///
/// - Parameter path: Blavatar URL (string encoded).
///
- private func optimizedBlavatarURL(from path: String) -> URL? {
- let size = SiteIconDefaults.imageSizeInPixels
- let query = String(format: "d=404&s=%d", size)
+ private func optimizedBlavatarURL(from path: String, imageSize: CGSize = SiteIconDefaults.imageSize) -> URL? {
+ let size = imageSize.toPixels()
+ let query = String(format: "d=404&s=%d", Int(max(size.width, size.height)))
return parseURL(path: path, query: query)
}
@@ -130,13 +191,12 @@ private extension UIImageView {
///
/// - Parameter siteIconPath: SiteIcon URL (string encoded).
///
- private func optimizedPhotonURL(from path: String) -> URL? {
+ private func optimizedPhotonURL(from path: String, imageSize: CGSize = SiteIconDefaults.imageSize) -> URL? {
guard let url = URL(string: path) else {
return nil
}
- let size = CGSize(width: SiteIconDefaults.imageSize, height: SiteIconDefaults.imageSize)
- return PhotonImageURLHelper.photonURL(with: size, forImageURL: url)
+ return PhotonImageURLHelper.photonURL(with: imageSize, forImageURL: url)
}
@@ -184,3 +244,41 @@ extension UIImageView {
layer.borderColor = UIColor.clear.cgColor
}
}
+
+// MARK: - Logging Support
+
+/// This is just a temporary extension to try and narrow down the caused behind this issue: https://sentry.io/share/issue/3da4662c65224346bb3a731c131df13d/
+///
+private extension UIImageView {
+
+ private func logURLOptimization(from original: String, to optimized: URL) {
+ DDLogInfo("URL optimized from \(original) to \(optimized.absoluteString)")
+ }
+
+ private func logURLOptimization(from original: String, to optimized: URL, for blog: Blog) {
+ let blogInfo: String
+ if blog.isAccessibleThroughWPCom() {
+ blogInfo = "dot-com-accessible: \(blog.url ?? "unknown"), id: \(blog.dotComID ?? 0)"
+ } else {
+ blogInfo = "self-hosted with url: \(blog.url ?? "unknown")"
+ }
+
+ DDLogInfo("URL optimized from \(original) to \(optimized.absoluteString) for blog \(blogInfo)")
+ }
+}
+
+// MARK: - CGFloat Extension
+
+private extension CGSize {
+
+ func toPixels() -> CGSize {
+ return CGSize(width: width.toPixels(), height: height.toPixels())
+ }
+}
+
+private extension CGFloat {
+
+ func toPixels() -> CGFloat {
+ return self * UIScreen.main.scale
+ }
+}
diff --git a/WordPress/Classes/Extensions/UINavigationBar+Appearance.swift b/WordPress/Classes/Extensions/UINavigationBar+Appearance.swift
new file mode 100644
index 000000000000..45cc4b0d84a9
--- /dev/null
+++ b/WordPress/Classes/Extensions/UINavigationBar+Appearance.swift
@@ -0,0 +1,7 @@
+import UIKit
+
+extension UINavigationBar {
+ class func standardTitleTextAttributes() -> [NSAttributedString.Key: Any] {
+ return appearance().standardAppearance.titleTextAttributes
+ }
+}
diff --git a/WordPress/Classes/Extensions/UINavigationController+Helpers.swift b/WordPress/Classes/Extensions/UINavigationController+Helpers.swift
index d3dab8b0c759..c7462b5c40e8 100644
--- a/WordPress/Classes/Extensions/UINavigationController+Helpers.swift
+++ b/WordPress/Classes/Extensions/UINavigationController+Helpers.swift
@@ -2,7 +2,7 @@ import UIKit
extension UINavigationController {
override open var preferredStatusBarStyle: UIStatusBarStyle {
- return .lightContent
+ return WPStyleGuide.preferredStatusBarStyle
}
override open var childForStatusBarStyle: UIViewController? {
@@ -15,21 +15,16 @@ extension UINavigationController {
@objc func scrollContentToTopAnimated(_ animated: Bool) {
guard viewControllers.count == 1 else { return }
- let scrollToTop = { (scrollView: UIScrollView) in
- let offset = CGPoint(x: 0, y: -scrollView.contentInset.top)
- scrollView.setContentOffset(offset, animated: animated)
- }
-
if let topViewController = topViewController as? WPScrollableViewController {
topViewController.scrollViewToTop()
} else if let scrollView = topViewController?.view as? UIScrollView {
// If the view controller's view is a scrollview
- scrollToTop(scrollView)
+ scrollView.scrollToTop(animated: animated)
} else if let scrollViews = topViewController?.view.subviews.filter({ $0 is UIScrollView }) as? [UIScrollView] {
// If one of the top level views of the view controller's view
// is a scrollview
if let scrollView = scrollViews.first {
- scrollToTop(scrollView)
+ scrollView.scrollToTop(animated: animated)
}
}
}
@@ -39,8 +34,8 @@ extension UINavigationController {
/// If this issue is addressed by Apple in following release we can remove this override.
///
@objc override open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
- if #available(iOS 13, *), UIDevice.current.userInterfaceIdiom == .phone,
- let webKitVC = topViewController as? WebKitViewController {
+ if UIDevice.current.userInterfaceIdiom == .phone,
+ let webKitVC = topViewController as? WebKitViewController {
viewControllerToPresent.popoverPresentationController?.delegate = webKitVC
}
super.present(viewControllerToPresent, animated: flag, completion: completion)
diff --git a/WordPress/Classes/Extensions/UINavigationController+SplitViewFullscreen.swift b/WordPress/Classes/Extensions/UINavigationController+SplitViewFullscreen.swift
index a87db035af33..f0d1cdc834f5 100644
--- a/WordPress/Classes/Extensions/UINavigationController+SplitViewFullscreen.swift
+++ b/WordPress/Classes/Extensions/UINavigationController+SplitViewFullscreen.swift
@@ -13,7 +13,7 @@ fileprivate let fadeAnimationDuration: TimeInterval = 0.1
// UIKit glitch.
extension UINavigationController {
@objc func pushFullscreenViewController(_ viewController: UIViewController, animated: Bool) {
- guard let splitViewController = splitViewController, splitViewController.preferredDisplayMode != .primaryHidden else {
+ guard let splitViewController = splitViewController, splitViewController.preferredDisplayMode != .secondaryOnly else {
pushViewController(viewController, animated: animated)
return
}
@@ -54,9 +54,9 @@ extension UIView {
/// Hides this view by inserting a snapshot into the view hierarchy.
///
- /// - Parameter afterScreenUpdates: A Boolean value that specifies whether
- /// the snapshot should be taken after recent changes have been
- /// incorporated. Pass the value false to capture the screen in
+ /// - Parameter afterScreenUpdates: A Boolean value that specifies whether
+ /// the snapshot should be taken after recent changes have been
+ /// incorporated. Pass the value false to capture the screen in
/// its current state, which might not include recent changes.
@objc func hideWithBlankingSnapshot(afterScreenUpdates: Bool = false) {
if subviews.first is BlankingView {
@@ -90,18 +90,14 @@ extension UIView {
extension UINavigationBar {
@objc func fadeOutNavigationItems(animated: Bool = true) {
- if let barTintColor = barTintColor {
- fadeNavigationItems(toColor: barTintColor, animated: animated)
- }
+ fadeNavigationItems(withTintColor: .appBarBackground, textColor: .appBarBackground, animated: animated)
}
@objc func fadeInNavigationItemsIfNecessary(animated: Bool = true) {
- if tintColor != UIColor.white {
- fadeNavigationItems(toColor: UIColor.white, animated: animated)
- }
+ fadeNavigationItems(withTintColor: .appBarTint, textColor: .appBarText, animated: animated)
}
- private func fadeNavigationItems(toColor color: UIColor, animated: Bool) {
+ private func fadeNavigationItems(withTintColor tintColor: UIColor, textColor: UIColor, animated: Bool) {
if animated {
// We're using CAAnimation because the various navigation item properties
// didn't seem to animate using a standard UIView animation block.
@@ -112,8 +108,11 @@ extension UINavigationBar {
layer.add(fadeAnimation, forKey: "fadeNavigationBar")
}
- titleTextAttributes = [.foregroundColor: color]
- tintColor = color
+ self.tintColor = tintColor
+
+ var attributes = titleTextAttributes
+ attributes?[.foregroundColor] = textColor
+ titleTextAttributes = attributes
}
}
diff --git a/WordPress/Classes/Extensions/UIPopoverPresentationController+PopoverAnchor.swift b/WordPress/Classes/Extensions/UIPopoverPresentationController+PopoverAnchor.swift
new file mode 100644
index 000000000000..6fcef97a4ed7
--- /dev/null
+++ b/WordPress/Classes/Extensions/UIPopoverPresentationController+PopoverAnchor.swift
@@ -0,0 +1,9 @@
+import UIKit
+
+extension UIPopoverPresentationController {
+
+ enum PopoverAnchor {
+ case view(UIView)
+ case barButtonItem(UIBarButtonItem)
+ }
+}
diff --git a/WordPress/Classes/Extensions/UIScrollView+Helpers.swift b/WordPress/Classes/Extensions/UIScrollView+Helpers.swift
new file mode 100644
index 000000000000..3fa4dc93a4af
--- /dev/null
+++ b/WordPress/Classes/Extensions/UIScrollView+Helpers.swift
@@ -0,0 +1,143 @@
+import UIKit
+
+extension UIScrollView {
+
+ // MARK: - Vertical scrollview
+
+ // Scroll to a specific view in a vertical scrollview so that it's top is at the top our scrollview
+ @objc func scrollVerticallyToView(_ view: UIView, animated: Bool) {
+ if let origin = view.superview {
+
+ // Get the Y position of your child view
+ let childStartPoint = origin.convert(view.frame.origin, to: self)
+
+ // Scroll to a rectangle starting at the Y of your subview, with a height of the scrollview safe area
+ // if the bottom of the rectangle is within the content size height.
+ //
+ // Otherwise, scroll all the way to the bottom.
+ //
+ if childStartPoint.y + safeAreaLayoutGuide.layoutFrame.height < contentSize.height {
+ let targetRect = CGRect(x: 0,
+ y: childStartPoint.y - Constants.targetRectPadding,
+ width: Constants.targetRectDimension,
+ height: safeAreaLayoutGuide.layoutFrame.height)
+ scrollRectToVisible(targetRect, animated: animated)
+
+ // This ensures scrolling to the correct position, especially when there are layout changes
+ //
+ // See: https://stackoverflow.com/a/35437399
+ //
+ layoutIfNeeded()
+ } else {
+ scrollToBottom(animated: true)
+ }
+ }
+ }
+
+ @objc func scrollToTop(animated: Bool) {
+ let topOffset = CGPoint(x: 0, y: -adjustedContentInset.top)
+ setContentOffset(topOffset, animated: animated)
+ layoutIfNeeded()
+ }
+
+ @objc func scrollToBottom(animated: Bool) {
+ let bottomOffset = CGPoint(x: 0, y: contentSize.height - bounds.size.height + adjustedContentInset.bottom)
+ if bottomOffset.y > 0 {
+ setContentOffset(bottomOffset, animated: animated)
+ layoutIfNeeded()
+ }
+ }
+
+ // MARK: - Horizontal scrollview
+
+ // Scroll to a specific view in a horizontal scrollview so that it's leading edge is at the leading edge of our scrollview
+ @objc func scrollHorizontallyToView(_ view: UIView, animated: Bool) {
+ if let origin = view.superview {
+ let childStartPoint = origin.convert(view.frame.origin, to: self).x
+ let childEndPoint = childStartPoint + view.frame.width
+ if userInterfaceLayoutDirection() == .leftToRight {
+ normalScrollHorizontallyToPoint(childStartPoint, animated: true)
+ } else {
+ flippedScrollHorizontallyToPoint(childEndPoint, animated: true)
+ }
+ }
+ }
+
+ /// Scroll horizontally to a view with the provided starting point
+ /// Used when the layout direction is Left to Right
+ /// - Parameters:
+ /// - point: The point to scroll to. Normally the starting point of the view.
+ /// - animated: `true` if the scrolling should be animated, `false` if it should be immediate.
+ private func normalScrollHorizontallyToPoint(_ point: CGFloat, animated: Bool) {
+ // Scroll to a rectangle starting at the X of your subview, with a width of the scrollview safe area
+ // if the end of the rectangle is within the content size width.
+ //
+ // Otherwise, scroll all the way to the end.
+ //
+ if point + safeAreaLayoutGuide.layoutFrame.width < contentSize.width {
+ let targetRect = CGRect(x: point - Constants.targetRectPadding,
+ y: 0,
+ width: safeAreaLayoutGuide.layoutFrame.width,
+ height: Constants.targetRectDimension)
+ scrollRectToVisible(targetRect, animated: animated)
+
+ // This ensures scrolling to the correct position, especially when there are layout changes
+ //
+ // See: https://stackoverflow.com/a/35437399
+ //
+ layoutIfNeeded()
+ } else {
+ scrollToEnd(animated: true)
+ }
+ }
+
+ /// Scroll horizontally to a view with the provided end point
+ /// Used when the layout direction is Right to Left
+ /// - Parameters:
+ /// - point: The point to scroll to. Normally the ending point of the view.
+ /// - animated: `true` if the scrolling should be animated, `false` if it should be immediate.
+ private func flippedScrollHorizontallyToPoint(_ point: CGFloat, animated: Bool) {
+ // Scroll to a rectangle ending at the X of your subview, with a width of the scrollview safe area
+ // if the start of the rectangle is within the content size width.
+ //
+ // Otherwise, scroll all the way to the start.
+ //
+ if point - safeAreaLayoutGuide.layoutFrame.width > 0 {
+ let targetRect = CGRect(x: point - safeAreaLayoutGuide.layoutFrame.width,
+ y: 0,
+ width: safeAreaLayoutGuide.layoutFrame.width,
+ height: Constants.targetRectDimension)
+ scrollRectToVisible(targetRect, animated: animated)
+
+ // This ensures scrolling to the correct position, especially when there are layout changes
+ //
+ // See: https://stackoverflow.com/a/35437399
+ //
+ layoutIfNeeded()
+ } else {
+ scrollToStart(animated: true)
+ }
+ }
+
+ func scrollToEnd(animated: Bool) {
+ let endOffset = CGPoint(x: contentSize.width - bounds.size.width, y: 0)
+ if endOffset.x > 0 {
+ setContentOffset(endOffset, animated: animated)
+ layoutIfNeeded()
+ }
+ }
+
+ func scrollToStart(animated: Bool) {
+ let startOffset = CGPoint(x: 0, y: 0)
+ setContentOffset(startOffset, animated: animated)
+ layoutIfNeeded()
+ }
+
+ private enum Constants {
+ /// An arbitrary placeholder value for the target rect -- must be some value larger than 0
+ static let targetRectDimension: CGFloat = 1
+
+ /// Padding for the target rect
+ static let targetRectPadding: CGFloat = 20
+ }
+}
diff --git a/WordPress/Classes/Extensions/UITableViewCell+enableDisable.swift b/WordPress/Classes/Extensions/UITableViewCell+enableDisable.swift
new file mode 100644
index 000000000000..4642d64387f2
--- /dev/null
+++ b/WordPress/Classes/Extensions/UITableViewCell+enableDisable.swift
@@ -0,0 +1,20 @@
+import WordPressShared
+
+extension UITableViewCell {
+ /// Enable cell interaction
+ @objc func enable() {
+ isUserInteractionEnabled = true
+ textLabel?.isEnabled = true
+ textLabel?.textColor = .text
+ detailTextLabel?.textColor = .listSmallIcon
+ }
+
+ /// Disable cell interaction
+ @objc func disable() {
+ accessoryType = .none
+ isUserInteractionEnabled = false
+ textLabel?.isEnabled = false
+ textLabel?.textColor = .neutral(.shade20)
+ detailTextLabel?.textColor = .neutral(.shade20)
+ }
+}
diff --git a/WordPress/Classes/Extensions/UITextField+WorkaroundContinueIssue.swift b/WordPress/Classes/Extensions/UITextField+WorkaroundContinueIssue.swift
new file mode 100644
index 000000000000..c26ef1d79428
--- /dev/null
+++ b/WordPress/Classes/Extensions/UITextField+WorkaroundContinueIssue.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+@objc
+extension UITextField {
+
+ /// This method takes care of resolving whether the iOS version is vulnerable to the Bulgarian / Icelandic keyboard crash issue
+ /// by Apple. Once the issue is resolved by Apple we should consider setting an upper iOS version to limit this workaround.
+ ///
+ /// Once we drop support for iOS 14, we could remove this extension entirely.
+ ///
+ public class func shouldActivateWorkaroundForBulgarianKeyboardCrash() -> Bool {
+ return true
+ }
+
+ /// We're swizzling `UITextField.becomeFirstResponder()` so that we can fix an issue with
+ /// Bulgarian and Icelandic keyboards when appropriate.
+ ///
+ /// Ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/15187
+ ///
+ @objc
+ class func activateWorkaroundForBulgarianKeyboardCrash() {
+ guard let original = class_getInstanceMethod(
+ UITextField.self,
+ #selector(UITextField.becomeFirstResponder)),
+ let new = class_getInstanceMethod(
+ UITextField.self,
+ #selector(UITextField.swizzledBecomeFirstResponder)) else {
+
+ DDLogError("Could not activate workaround for Bulgarian keyboard crash.")
+
+ return
+ }
+
+ method_exchangeImplementations(original, new)
+ }
+
+ /// This method simply replaces the `returnKeyType == .continue` with
+ /// `returnKeyType == .next`when the Bulgarian Keyboard crash workaround is needed.
+ ///
+ public func swizzledBecomeFirstResponder() {
+ if UITextField.shouldActivateWorkaroundForBulgarianKeyboardCrash(),
+ returnKeyType == .continue {
+ returnKeyType = .next
+ }
+
+ // This can look confusing - it's basically calling the original method to
+ // make sure we don't disrupt anything.
+ swizzledBecomeFirstResponder()
+ }
+}
diff --git a/WordPress/Classes/Extensions/UIView+Borders.swift b/WordPress/Classes/Extensions/UIView+Borders.swift
index e71038718dc9..832b07e182a1 100644
--- a/WordPress/Classes/Extensions/UIView+Borders.swift
+++ b/WordPress/Classes/Extensions/UIView+Borders.swift
@@ -25,6 +25,19 @@ extension UIView {
return borderView
}
+ @discardableResult
+ func addBottomBorder(withColor bgColor: UIColor, leadingMargin: CGFloat) -> UIView {
+ let borderView = makeBorderView(withColor: bgColor)
+
+ NSLayoutConstraint.activate([
+ borderView.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth),
+ borderView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ borderView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: leadingMargin),
+ borderView.trailingAnchor.constraint(equalTo: trailingAnchor)
+ ])
+ return borderView
+ }
+
private func makeBorderView(withColor: UIColor) -> UIView {
let borderView = UIView()
borderView.backgroundColor = withColor
diff --git a/WordPress/Classes/Extensions/UIView+PinSubviewPriority.swift b/WordPress/Classes/Extensions/UIView+PinSubviewPriority.swift
new file mode 100644
index 000000000000..b11dde317e52
--- /dev/null
+++ b/WordPress/Classes/Extensions/UIView+PinSubviewPriority.swift
@@ -0,0 +1,23 @@
+import Foundation
+import UIKit
+
+extension UIView {
+ /// Adds constraints that pin a subview to self with padding insets and an applied priority.
+ ///
+ /// - Parameters:
+ /// - subview: a subview to be pinned to self.
+ /// - insets: spacing between each subview edge to self. A positive value for an edge indicates that the subview is inside self on that edge.
+ /// - priority: the `UILayoutPriority` to be used for the constraints
+ @objc public func pinSubviewToAllEdges(_ subview: UIView, insets: UIEdgeInsets = .zero, priority: UILayoutPriority = .defaultHigh) {
+ let constraints = [
+ leadingAnchor.constraint(equalTo: subview.leadingAnchor, constant: -insets.left),
+ trailingAnchor.constraint(equalTo: subview.trailingAnchor, constant: insets.right),
+ topAnchor.constraint(equalTo: subview.topAnchor, constant: -insets.top),
+ bottomAnchor.constraint(equalTo: subview.bottomAnchor, constant: insets.bottom),
+ ]
+
+ constraints.forEach { $0.priority = priority }
+
+ NSLayoutConstraint.activate(constraints)
+ }
+}
diff --git a/WordPress/Classes/Extensions/UIViewController+Dismissal.swift b/WordPress/Classes/Extensions/UIViewController+Dismissal.swift
new file mode 100644
index 000000000000..a13de365c8b3
--- /dev/null
+++ b/WordPress/Classes/Extensions/UIViewController+Dismissal.swift
@@ -0,0 +1,25 @@
+import Foundation
+
+extension UIViewController {
+ /// iOS's `isBeingDismissed` can return `false` if the VC is being dismissed indirectly, by one of its ancestors
+ /// being dismissed. This method returns `true` if the VC is being dismissed directly, or if one of its ancestors is being
+ /// dismissed.
+ ///
+ func isBeingDismissedDirectlyOrByAncestor() -> Bool {
+ guard !isBeingDismissed else {
+ return true
+ }
+
+ var current: UIViewController = self
+
+ while let ancestor = current.parent {
+ guard !ancestor.isBeingDismissed else {
+ return true
+ }
+
+ current = ancestor
+ }
+
+ return false
+ }
+}
diff --git a/WordPress/Classes/Extensions/UIViewController+NoResults.swift b/WordPress/Classes/Extensions/UIViewController+NoResults.swift
index 878c3624642f..048cec2d211f 100644
--- a/WordPress/Classes/Extensions/UIViewController+NoResults.swift
+++ b/WordPress/Classes/Extensions/UIViewController+NoResults.swift
@@ -1,4 +1,4 @@
-protocol NoResultsViewHost: class { }
+protocol NoResultsViewHost: AnyObject { }
extension NoResultsViewHost where Self: UIViewController {
typealias NoResultsCustomizationBlock = (NoResultsViewController) -> Void
@@ -22,6 +22,7 @@ extension NoResultsViewHost where Self: UIViewController {
/// - view: The no results view parentView. Required.
/// - title: Main descriptive text. Required.
/// - subtitle: Secondary descriptive text. Optional.
+ /// - noConnectionSubtitle: Secondary descriptive text to use specifically when there is no network connection. Optional.
/// - buttonTitle: Title of action button. Optional.
/// - attributedSubtitle: Secondary descriptive attributed text. Optional.
/// - attributedSubtitleConfiguration: Called after default styling, for subtitle attributed text customization.
@@ -33,6 +34,7 @@ extension NoResultsViewHost where Self: UIViewController {
func configureAndDisplayNoResults(on view: UIView,
title: String,
subtitle: String? = nil,
+ noConnectionSubtitle: String? = nil,
buttonTitle: String? = nil,
attributedSubtitle: NSAttributedString? = nil,
attributedSubtitleConfiguration: NoResultsAttributedSubtitleConfiguration? = nil,
@@ -44,6 +46,7 @@ extension NoResultsViewHost where Self: UIViewController {
noResultsViewController.configure(title: title,
buttonTitle: buttonTitle,
subtitle: subtitle,
+ noConnectionSubtitle: noConnectionSubtitle,
attributedSubtitle: attributedSubtitle,
attributedSubtitleConfiguration: attributedSubtitleConfiguration,
image: image,
diff --git a/WordPress/Classes/Extensions/UIViewController+Notice.swift b/WordPress/Classes/Extensions/UIViewController+Notice.swift
index 25fe65afd2b3..db9ac41246fe 100644
--- a/WordPress/Classes/Extensions/UIViewController+Notice.swift
+++ b/WordPress/Classes/Extensions/UIViewController+Notice.swift
@@ -1,46 +1,33 @@
import Foundation
import WordPressFlux
-import WordPressShared
-
extension UIViewController {
- /// Dispatch a Notice for subscribing notification action
- ///
- /// - Parameters:
- /// - siteTitle: Title to display
- /// - siteID: Site id to be used
- func dispatchSubscribingNotificationNotice(with siteTitle: String?, siteID: NSNumber?) {
- guard let siteTitle = siteTitle, let siteID = siteID else {
- return
- }
-
- let localizedTitle = NSLocalizedString("Following %@", comment: "Title for a notice informing the user that they've successfully followed a site. %@ is a placeholder for the name of the site.")
- let title = String(format: localizedTitle, siteTitle)
- let message = NSLocalizedString("Enable site notifications?", comment: "Message informing the user about the enable notifications action")
- let buttonTitle = NSLocalizedString("Enable", comment: "Button title about the enable notifications action")
+ @objc func displayNotice(title: String, message: String? = nil) {
+ displayActionableNotice(title: title, message: message)
+ }
- let notice = Notice(title: title,
- message: message,
- feedbackType: .success,
- notificationInfo: nil,
- actionTitle: buttonTitle) { _ in
- let context = ContextManager.sharedInstance().mainContext
- let service = ReaderTopicService(managedObjectContext: context)
- service.toggleSubscribingNotifications(for: siteID.intValue, subscribe: true, {
- WPAnalytics.track(.readerListNotificationEnabled)
- })
- }
- ActionDispatcher.dispatch(NoticeAction.post(notice))
+ @objc func displayActionableNotice(title: String,
+ message: String? = nil,
+ actionTitle: String? = nil,
+ actionHandler: ((Bool) -> Void)? = nil) {
+ displayActionableNotice(title: title, message: message, style: NormalNoticeStyle(), actionTitle: actionTitle, actionHandler: actionHandler)
}
-}
-@objc extension UIViewController {
- @objc func displayNotice(title: String, message: String? = nil) {
- let notice = Notice(title: title, message: message)
+ // NoticeStyle is Swift only, so this method is needed to set it.
+ func displayActionableNotice(title: String,
+ message: String? = nil,
+ style: NoticeStyle = NormalNoticeStyle(),
+ actionTitle: String? = nil,
+ actionHandler: ((Bool) -> Void)? = nil) {
+ let notice = Notice(title: title, message: message, style: style, actionTitle: actionTitle, actionHandler: actionHandler)
ActionDispatcher.dispatch(NoticeAction.post(notice))
}
@objc func dismissNotice() {
ActionDispatcher.dispatch(NoticeAction.dismiss)
}
+
+ @objc func dismissQuickStartTaskCompleteNotice() {
+ QuickStartTourGuide.shared.dismissTaskCompleteNotice()
+ }
}
diff --git a/WordPress/Classes/Extensions/URL+Helpers.swift b/WordPress/Classes/Extensions/URL+Helpers.swift
index 0941f52eab24..6969c5a837ec 100644
--- a/WordPress/Classes/Extensions/URL+Helpers.swift
+++ b/WordPress/Classes/Extensions/URL+Helpers.swift
@@ -116,23 +116,7 @@ extension URL {
}
}
- func appendingHideMasterbarParameters() -> URL? {
- guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
- return nil
- }
- // FIXME: This code is commented out because of a menu navigation issue that can occur while
- // viewing a site within the webview. See https://github.com/wordpress-mobile/WordPress-iOS/issues/9796
- // for more details.
- //
- // var queryItems = components.queryItems ?? []
- // queryItems.append(URLQueryItem(name: "preview", value: "true"))
- // queryItems.append(URLQueryItem(name: "iframe", value: "true"))
- // components.queryItems = queryItems
- /////
- return components.url
- }
-
- var hasWordPressDotComHostname: Bool {
+ var isHostedAtWPCom: Bool {
guard let host = host else {
return false
}
@@ -143,7 +127,44 @@ extension URL {
var isWordPressDotComPost: Bool {
// year, month, day, slug
let components = pathComponents.filter({ $0 != "/" })
- return components.count == 4 && hasWordPressDotComHostname
+ return components.count == 4 && isHostedAtWPCom
+ }
+
+
+ /// Handle the common link protocols.
+ /// - tel: open a prompt to call the phone number
+ /// - sms: compose new message in iMessage app
+ /// - mailto: compose new email in Mail app
+ ///
+ var isLinkProtocol: Bool {
+ guard let urlScheme = scheme else {
+ return false
+ }
+
+ let linkProtocols = ["tel", "sms", "mailto"]
+ if linkProtocols.contains(urlScheme) && UIApplication.shared.canOpenURL(self) {
+ return true
+ }
+
+ return false
+ }
+
+
+ /// Does a quick test to see if 2 urls are equal to each other by
+ /// using just the hosts and paths. This ignores any query items, or hashes
+ /// on the urls
+ func isHostAndPathEqual(to url: URL) -> Bool {
+ guard
+ let components1 = URLComponents(url: self, resolvingAgainstBaseURL: true),
+ let components2 = URLComponents(url: url, resolvingAgainstBaseURL: true)
+ else {
+ return false
+ }
+
+ let check1 = (components1.host ?? "") + components1.path
+ let check2 = (components2.host ?? "") + components2.path
+
+ return check1 == check2
}
}
@@ -159,10 +180,6 @@ extension NSURL {
return NSNumber(value: fileSize)
}
- @objc func appendingHideMasterbarParameters() -> NSURL? {
- let url = self as URL
- return url.appendingHideMasterbarParameters() as NSURL?
- }
}
extension URL {
@@ -188,3 +205,16 @@ extension URL {
return newComponents.url ?? self
}
}
+
+extension URL {
+ /// Appends query items to the URL.
+ /// - Parameter newQueryItems: The new query items to add to the URL. These will **not** overwrite any existing items but are appended to the existing list.
+ /// - Returns: The URL with added query items.
+ func appendingQueryItems(_ newQueryItems: [URLQueryItem]) -> URL {
+ var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
+ var queryItems = components?.queryItems ?? []
+ queryItems.append(contentsOf: newQueryItems)
+ components?.queryItems = queryItems
+ return components?.url ?? self
+ }
+}
diff --git a/WordPress/Classes/Extensions/URLQueryItem+Parameters.swift b/WordPress/Classes/Extensions/URLQueryItem+Parameters.swift
new file mode 100644
index 000000000000..4fe9e5edba48
--- /dev/null
+++ b/WordPress/Classes/Extensions/URLQueryItem+Parameters.swift
@@ -0,0 +1,11 @@
+
+extension URLQueryItem {
+ /// Query Parameters to be used for the WP Stories feature.
+ /// Can be appended to the URL of any WordPress blog post.
+ enum WPStory {
+ /// Opens the story in fullscreen.
+ static let fullscreen = URLQueryItem(name: "wp-story-load-in-fullscreen", value: "true")
+ /// Begins playing the story immediately.
+ static let playOnLoad = URLQueryItem(name: "wp-story-play-on-load", value: "true")
+ }
+}
diff --git a/WordPress/Classes/Extensions/WKWebView+Preview.swift b/WordPress/Classes/Extensions/WKWebView+Preview.swift
deleted file mode 100644
index 4f5b1ee1d209..000000000000
--- a/WordPress/Classes/Extensions/WKWebView+Preview.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-import WebKit
-
-/// This extension contains a couple of small hacks used in site previews
-/// to hide various wpcom UI elements from webpages or prevent interaction.
-///
-extension WKWebView {
-
- func prepareWPComPreview() {
- hideWPComPreviewBanners()
- preventInteraction()
- }
-
- /// Hides the 'Create your website at WordPress.com' getting started bar,
- /// displayed on logged out sites, as well as the cookie widget banner.
- func hideWPComPreviewBanners() {
- let javascript = """
- document.querySelector('html').style.cssText += '; margin-top: 0 !important;';\n document.getElementById('wpadminbar').style.display = 'none';\n
- document.getElementsByClassName("widget_eu_cookie_law_widget")[0].style += '; display: none !important;';\n
- """
-
- evaluateJavaScript(javascript, completionHandler: nil)
- }
-
- /// Prevents interaction on the current page using CSS.
- func preventInteraction() {
- let javascript = """
- document.querySelector('*').style.cssText += '; pointer-events: none; -webkit-tap-highlight-color: rgba(0,0,0,0);';\n
- """
-
- evaluateJavaScript(javascript, completionHandler: nil)
- }
-}
diff --git a/WordPress/Classes/Extensions/WKWebView+UserAgent.swift b/WordPress/Classes/Extensions/WKWebView+UserAgent.swift
index b2aa6e35c35f..730c6a36615b 100644
--- a/WordPress/Classes/Extensions/WKWebView+UserAgent.swift
+++ b/WordPress/Classes/Extensions/WKWebView+UserAgent.swift
@@ -13,7 +13,7 @@ extension WKWebView {
func userAgent() -> String {
guard let userAgent = value(forKey: WKWebView.userAgentKey) as? String,
userAgent.count > 0 else {
- CrashLogging.logMessage(
+ WordPressAppDelegate.crashLogging?.logMessage(
"This method for retrieveing the user agent seems to be no longer working. We need to figure out an alternative.",
properties: [:],
level: .error)
diff --git a/WordPress/Classes/Extensions/WordPressSupportSourceTag+Editor.swift b/WordPress/Classes/Extensions/WordPressSupportSourceTag+Editor.swift
new file mode 100644
index 000000000000..ccbcf4d9126c
--- /dev/null
+++ b/WordPress/Classes/Extensions/WordPressSupportSourceTag+Editor.swift
@@ -0,0 +1,8 @@
+import Foundation
+import WordPressAuthenticator
+
+extension WordPressSupportSourceTag {
+ public static var editorHelp: WordPressSupportSourceTag {
+ return WordPressSupportSourceTag(name: "editorHelp", origin: "origin:editor-help")
+ }
+}
diff --git a/WordPress/Classes/Models/AbstractPost+Autosave.swift b/WordPress/Classes/Models/AbstractPost+Autosave.swift
new file mode 100644
index 000000000000..8f2ca445eaa3
--- /dev/null
+++ b/WordPress/Classes/Models/AbstractPost+Autosave.swift
@@ -0,0 +1,11 @@
+import Foundation
+
+extension AbstractPost {
+ /// An autosave revision may include post title, content and/or excerpt.
+ var hasAutosaveRevision: Bool {
+ guard let autosaveRevisionIdentifier = autosaveIdentifier?.intValue else {
+ return false
+ }
+ return autosaveRevisionIdentifier > 0
+ }
+}
diff --git a/WordPress/Classes/Models/AbstractPost+Blaze.swift b/WordPress/Classes/Models/AbstractPost+Blaze.swift
new file mode 100644
index 000000000000..7288e99fec53
--- /dev/null
+++ b/WordPress/Classes/Models/AbstractPost+Blaze.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+extension AbstractPost {
+
+ var canBlaze: Bool {
+ return blog.isBlazeApproved && status == .publish && password == nil
+ }
+}
diff --git a/WordPress/Classes/Models/AbstractPost+Local.swift b/WordPress/Classes/Models/AbstractPost+Local.swift
new file mode 100644
index 000000000000..5736b7d512cf
--- /dev/null
+++ b/WordPress/Classes/Models/AbstractPost+Local.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+extension AbstractPost {
+ /// Returns true if the post is a draft and has never been uploaded to the server.
+ var isLocalDraft: Bool {
+ return self.isDraft() && !self.hasRemote()
+ }
+
+ var isLocalRevision: Bool {
+ return self.originalIsDraft() && self.isRevision() && self.remoteStatus == .local
+ }
+
+ /// Count posts that have never been uploaded to the server.
+ ///
+ /// - Parameter context: A `NSManagedObjectContext` in which to count the posts
+ /// - Returns: number of local posts in the given context.
+ static func countLocalPosts(in context: NSManagedObjectContext) -> Int {
+ let request = NSFetchRequest(entityName: NSStringFromClass(AbstractPost.self))
+ request.predicate = NSPredicate(format: "postID = NULL OR postID <= 0")
+ return (try? context.count(for: request)) ?? 0
+ }
+}
diff --git a/WordPress/Classes/Models/AbstractPost+MarkAsFailedAndDraftIfNeeded.swift b/WordPress/Classes/Models/AbstractPost+MarkAsFailedAndDraftIfNeeded.swift
new file mode 100644
index 000000000000..fced068bf6e9
--- /dev/null
+++ b/WordPress/Classes/Models/AbstractPost+MarkAsFailedAndDraftIfNeeded.swift
@@ -0,0 +1,36 @@
+@objc extension AbstractPost {
+
+ // MARK: - Updating the Remote Status
+
+ /// Updates the post after an upload failure.
+ ///
+ /// Local-only pages will be reverted back to `.draft` to avoid scenarios like this:
+ ///
+ /// 1. A locally published page upload failed
+ /// 2. The user presses the Page List's Retry button.
+ /// 3. The page upload is retried and the page is **published**.
+ ///
+ /// This is an unexpected behavior and can be surprising for the user. We'd want the user to
+ /// explicitly press on a “Publish” button instead.
+ ///
+ /// Posts' statuses are kept as is because we support automatic uploading of posts.
+ ///
+ /// - Important: This logic could have been placed in the setter for `remoteStatus`, but it's my belief
+ /// that our code will be much more resilient if we decouple the act of setting the `remoteStatus` value
+ /// and the logic behind processing an upload failure. In fact I think the `remoteStatus` setter should
+ /// eventually be made private.
+ /// - SeeAlso: PostCoordinator.resume
+ ///
+ func markAsFailedAndDraftIfNeeded() {
+ guard self.remoteStatus != .failed else {
+ return
+ }
+
+ self.remoteStatus = .failed
+
+ if !self.hasRemote() && self is Page {
+ self.status = .draft
+ self.dateModified = Date()
+ }
+ }
+}
diff --git a/WordPress/Classes/Models/AbstractPost+TitleForVisibility.swift b/WordPress/Classes/Models/AbstractPost+TitleForVisibility.swift
new file mode 100644
index 000000000000..b19b15960aaf
--- /dev/null
+++ b/WordPress/Classes/Models/AbstractPost+TitleForVisibility.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+extension AbstractPost {
+ static let passwordProtectedLabel = NSLocalizedString("Password protected", comment: "Privacy setting for posts set to 'Password protected'. Should be the same as in core WP.")
+ static let privateLabel = NSLocalizedString("Private", comment: "Privacy setting for posts set to 'Private'. Should be the same as in core WP.")
+ static let publicLabel = NSLocalizedString("Public", comment: "Privacy setting for posts set to 'Public' (default). Should be the same as in core WP.")
+
+ /// A title describing the status. Ie.: "Public" or "Private" or "Password protected"
+ @objc var titleForVisibility: String {
+ if password != nil {
+ return AbstractPost.passwordProtectedLabel
+ } else if status == .publishPrivate {
+ return AbstractPost.privateLabel
+ }
+
+ return AbstractPost.publicLabel
+ }
+}
diff --git a/WordPress/Classes/Models/AbstractPost.h b/WordPress/Classes/Models/AbstractPost.h
index 3175e05fbbc0..eb780151c39e 100644
--- a/WordPress/Classes/Models/AbstractPost.h
+++ b/WordPress/Classes/Models/AbstractPost.h
@@ -96,7 +96,7 @@ typedef NS_ENUM(NSUInteger, AbstractPostRemoteStatus) {
- (NSString *)blavatarForDisplay;
- (NSString *)dateStringForDisplay;
- (BOOL)isMultiAuthorBlog;
-- (BOOL)isPrivate;
+- (BOOL)isPrivateAtWPCom;
- (BOOL)supportsStats;
@@ -152,6 +152,11 @@ typedef NS_ENUM(NSUInteger, AbstractPostRemoteStatus) {
*/
- (BOOL)isDraft;
+/**
+ Returns YES if the post is a published.
+ */
+- (BOOL)isPublished;
+
/**
Returns YES if the original post is a draft
*/
diff --git a/WordPress/Classes/Models/AbstractPost.m b/WordPress/Classes/Models/AbstractPost.m
index 16477d2f41c9..1e62605b84c2 100644
--- a/WordPress/Classes/Models/AbstractPost.m
+++ b/WordPress/Classes/Models/AbstractPost.m
@@ -1,6 +1,6 @@
#import "AbstractPost.h"
#import "Media.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "WordPress-Swift.h"
#import "BasePost.h"
@import WordPressKit;
@@ -402,6 +402,11 @@ - (BOOL)isDraft
return [self.status isEqualToString:PostStatusDraft];
}
+- (BOOL)isPublished
+{
+ return [self.status isEqualToString:PostStatusPublish];
+}
+
- (BOOL)originalIsDraft
{
if ([self.status isEqualToString:PostStatusDraft]) {
@@ -420,7 +425,7 @@ - (void)publishImmediately
- (BOOL)shouldPublishImmediately
{
- return [self originalIsDraft] && ![self hasFuturePublishDate];
+ return [self originalIsDraft] && [self dateCreatedIsNilOrEqualToDateModified];
}
- (NSString *)authorNameForDisplay
@@ -475,9 +480,9 @@ - (BOOL)supportsStats
return [self.blog supports:BlogFeatureStats] && [self hasRemote];
}
-- (BOOL)isPrivate
+- (BOOL)isPrivateAtWPCom
{
- return self.blog.isPrivate;
+ return self.blog.isPrivateAtWPCom;
}
- (BOOL)isMultiAuthorBlog
@@ -572,6 +577,17 @@ - (BOOL)hasLocalChanges
return YES;
}
+ if ( ((self.featuredImage != nil) && ![self.featuredImage.objectID isEqual: original.featuredImage.objectID]) ||
+ (self.featuredImage == nil && self.original.featuredImage != nil) ) {
+ return YES;
+ }
+
+ if ((self.authorID != original.authorID)
+ && (![self.authorID isEqual:original.authorID]))
+ {
+ return YES;
+ }
+
return NO;
}
diff --git a/WordPress/Classes/Models/AbstractPost.swift b/WordPress/Classes/Models/AbstractPost.swift
index 44d3803093a2..72595c119466 100644
--- a/WordPress/Classes/Models/AbstractPost.swift
+++ b/WordPress/Classes/Models/AbstractPost.swift
@@ -122,6 +122,10 @@ extension AbstractPost {
return content?.contains("\n\n"];
+ return self.content ? (self.content.isEmpty || isContentAnEmptyGBParagraph) : YES;
}
@end
diff --git a/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataClass.swift b/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataClass.swift
new file mode 100644
index 000000000000..0283d26c4ba4
--- /dev/null
+++ b/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataClass.swift
@@ -0,0 +1,7 @@
+import Foundation
+import CoreData
+
+@objc(BlockEditorSettingElement)
+public class BlockEditorSettingElement: NSManagedObject {
+
+}
diff --git a/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataProperties.swift b/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataProperties.swift
new file mode 100644
index 000000000000..c2cd60f429cc
--- /dev/null
+++ b/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataProperties.swift
@@ -0,0 +1,80 @@
+import Foundation
+import CoreData
+
+enum BlockEditorSettingElementTypes: String {
+ case color
+ case gradient
+ case experimentalFeatures
+
+ var valueKey: String {
+ self.rawValue
+ }
+}
+
+enum BlockEditorExperimentalFeatureKeys: String {
+ case galleryWithImageBlocks
+ case quoteBlockV2
+ case listBlockV2
+}
+
+extension BlockEditorSettingElement {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "BlockEditorSettingElement")
+ }
+
+ /// Stores the associated type that this object represents.
+ /// Available types are defined in `BlockEditorSettingElementTypes`
+ ///
+ @NSManaged public var type: String
+
+ /// Stores the value for the associated type. The associated field in the API response might differ based on the type.
+ ///
+ @NSManaged public var value: String
+
+ /// Stores a unique key associated to the `value`.
+ ///
+ @NSManaged public var slug: String
+
+ /// Stores a user friendly display name for the `slug`.
+ ///
+ @NSManaged public var name: String
+
+ /// Stores maintains the order as passed from the API
+ ///
+ @NSManaged public var order: Int
+
+ /// Stores a reference back to the parent `BlockEditorSettings`.
+ ///
+ @NSManaged public var settings: BlockEditorSettings
+}
+
+extension BlockEditorSettingElement: Identifiable {
+ var rawRepresentation: [String: String]? {
+ guard let type = BlockEditorSettingElementTypes(rawValue: self.type) else { return nil }
+ return [
+ #keyPath(BlockEditorSettingElement.slug): self.slug,
+ #keyPath(BlockEditorSettingElement.name): self.name,
+ type.valueKey: self.value
+ ]
+ }
+
+ convenience init(fromRawRepresentation rawObject: [String: String], type: BlockEditorSettingElementTypes, order: Int, context: NSManagedObjectContext) {
+ self.init(name: rawObject[ #keyPath(BlockEditorSettingElement.name)],
+ value: rawObject[type.valueKey],
+ slug: rawObject[#keyPath(BlockEditorSettingElement.slug)],
+ type: type,
+ order: order,
+ context: context)
+ }
+
+ convenience init(name: String?, value: String?, slug: String?, type: BlockEditorSettingElementTypes, order: Int, context: NSManagedObjectContext) {
+ self.init(context: context)
+
+ self.type = type.rawValue
+ self.value = value ?? ""
+ self.slug = slug ?? ""
+ self.name = name ?? ""
+ self.order = order
+ }
+}
diff --git a/WordPress/Classes/Models/BlockEditorSettings+CoreDataClass.swift b/WordPress/Classes/Models/BlockEditorSettings+CoreDataClass.swift
new file mode 100644
index 000000000000..43e2d73ab70e
--- /dev/null
+++ b/WordPress/Classes/Models/BlockEditorSettings+CoreDataClass.swift
@@ -0,0 +1,7 @@
+import Foundation
+import CoreData
+
+@objc(BlockEditorSettings)
+public class BlockEditorSettings: NSManagedObject {
+
+}
diff --git a/WordPress/Classes/Models/BlockEditorSettings+CoreDataProperties.swift b/WordPress/Classes/Models/BlockEditorSettings+CoreDataProperties.swift
new file mode 100644
index 000000000000..05ed69d83ffc
--- /dev/null
+++ b/WordPress/Classes/Models/BlockEditorSettings+CoreDataProperties.swift
@@ -0,0 +1,59 @@
+import Foundation
+import CoreData
+
+extension BlockEditorSettings {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "BlockEditorSettings")
+ }
+
+ /// Stores a n MD5 checksum representing the stored data. Used for a comparison to decide if the data has changed.
+ ///
+ @NSManaged public var checksum: String
+
+ /// Stores a Bool indicating if the theme supports Full Site Editing (FSE) or not. `true` means the theme is an FSE theme.
+ /// Default is `false`
+ ///
+ @NSManaged public var isFSETheme: Bool
+
+ /// Stores a date indicating the last time stamp that the settings were modified.
+ ///
+ @NSManaged public var lastUpdated: Date
+
+ /// Stores the raw JSON string that comes from the Global Styles Setting Request.
+ ///
+ @NSManaged public var rawStyles: String?
+
+ /// Stores the raw JSON string that comes from the Global Styles Setting Request.
+ ///
+ @NSManaged public var rawFeatures: String?
+
+ /// Stores a set of attributes describing values that are represented with arrays in the API request.
+ /// Available types are defined in `BlockEditorSettingElementTypes`
+ ///
+ @NSManaged public var elements: Set?
+
+ /// Stores a reference back to the parent blog.
+ ///
+ @NSManaged public var blog: Blog
+}
+
+// MARK: Generated accessors for elements
+extension BlockEditorSettings {
+
+ @objc(addElementsObject:)
+ @NSManaged public func addToElements(_ value: BlockEditorSettingElement)
+
+ @objc(removeElementsObject:)
+ @NSManaged public func removeFromElements(_ value: BlockEditorSettingElement)
+
+ @objc(addElements:)
+ @NSManaged public func addToElements(_ values: Set)
+
+ @objc(removeElements:)
+ @NSManaged public func removeFromElements(_ values: Set)
+}
+
+extension BlockEditorSettings: Identifiable {
+
+}
diff --git a/WordPress/Classes/Models/BlockEditorSettings+GutenbergEditorSettings.swift b/WordPress/Classes/Models/BlockEditorSettings+GutenbergEditorSettings.swift
new file mode 100644
index 000000000000..90f4a3a25c07
--- /dev/null
+++ b/WordPress/Classes/Models/BlockEditorSettings+GutenbergEditorSettings.swift
@@ -0,0 +1,112 @@
+import Foundation
+import WordPressKit
+import Gutenberg
+
+extension BlockEditorSettings: GutenbergEditorSettings {
+ public var colors: [[String: String]]? {
+ elementsByType(.color)
+ }
+
+ public var gradients: [[String: String]]? {
+ elementsByType(.gradient)
+ }
+
+ public var galleryWithImageBlocks: Bool {
+ return experimentalFeature(.galleryWithImageBlocks)
+ }
+
+ public var quoteBlockV2: Bool {
+ return experimentalFeature(.quoteBlockV2)
+ }
+
+ public var listBlockV2: Bool {
+ return experimentalFeature(.listBlockV2)
+ }
+
+ private func elementsByType(_ type: BlockEditorSettingElementTypes) -> [[String: String]]? {
+ return elements?.sorted(by: { (lhs, rhs) -> Bool in
+ return lhs.order >= rhs.order
+ }).compactMap({ (element) -> [String: String]? in
+ guard element.type == type.rawValue else { return nil }
+ return element.rawRepresentation
+ })
+ }
+
+ private func experimentalFeature(_ feature: BlockEditorExperimentalFeatureKeys) -> Bool {
+ guard let experimentalFeature = elements?.first(where: { (element) -> Bool in
+ guard element.type == BlockEditorSettingElementTypes.experimentalFeatures.rawValue else { return false }
+ return element.slug == feature.rawValue
+ }) else { return false }
+
+ return Bool(experimentalFeature.value) ?? false
+ }
+}
+
+extension BlockEditorSettings {
+ convenience init?(editorTheme: RemoteEditorTheme, context: NSManagedObjectContext) {
+ self.init(context: context)
+ self.isFSETheme = false
+ self.lastUpdated = Date()
+ self.checksum = editorTheme.checksum
+
+ var parsedElements = Set()
+ if let themeSupport = editorTheme.themeSupport {
+ themeSupport.colors?.enumerated().forEach({ (index, color) in
+ parsedElements.insert(BlockEditorSettingElement(fromRawRepresentation: color, type: .color, order: index, context: context))
+ })
+
+ themeSupport.gradients?.enumerated().forEach({ (index, gradient) in
+ parsedElements.insert(BlockEditorSettingElement(fromRawRepresentation: gradient, type: .gradient, order: index, context: context))
+ })
+ }
+
+ self.elements = parsedElements
+ }
+
+ convenience init?(remoteSettings: RemoteBlockEditorSettings, context: NSManagedObjectContext) {
+ self.init(context: context)
+ self.isFSETheme = remoteSettings.isFSETheme
+ self.lastUpdated = Date()
+ self.checksum = remoteSettings.checksum
+ self.rawStyles = remoteSettings.rawStyles
+ self.rawFeatures = remoteSettings.rawFeatures
+
+ var parsedElements = Set()
+
+ remoteSettings.colors?.enumerated().forEach({ (index, color) in
+ parsedElements.insert(BlockEditorSettingElement(fromRawRepresentation: color, type: .color, order: index, context: context))
+ })
+
+ remoteSettings.gradients?.enumerated().forEach({ (index, gradient) in
+ parsedElements.insert(BlockEditorSettingElement(fromRawRepresentation: gradient, type: .gradient, order: index, context: context))
+ })
+
+ // Experimental Features
+ let galleryKey = BlockEditorExperimentalFeatureKeys.galleryWithImageBlocks.rawValue
+ let galleryRefactor = BlockEditorSettingElement(name: galleryKey,
+ value: "\(remoteSettings.galleryWithImageBlocks)",
+ slug: galleryKey,
+ type: .experimentalFeatures,
+ order: 0,
+ context: context)
+ let quoteKey = BlockEditorExperimentalFeatureKeys.quoteBlockV2.rawValue
+ let quoteRefactor = BlockEditorSettingElement(name: quoteKey,
+ value: "\(remoteSettings.quoteBlockV2)",
+ slug: quoteKey,
+ type: .experimentalFeatures,
+ order: 1,
+ context: context)
+ let listKey = BlockEditorExperimentalFeatureKeys.listBlockV2.rawValue
+ let listRefactor = BlockEditorSettingElement(name: listKey,
+ value: "\(remoteSettings.listBlockV2)",
+ slug: listKey,
+ type: .experimentalFeatures,
+ order: 2,
+ context: context)
+ parsedElements.insert(galleryRefactor)
+ parsedElements.insert(quoteRefactor)
+ parsedElements.insert(listRefactor)
+
+ self.elements = parsedElements
+ }
+}
diff --git a/WordPress/Classes/Models/Blocking/BlockedAuthor.swift b/WordPress/Classes/Models/Blocking/BlockedAuthor.swift
new file mode 100644
index 000000000000..21db27749ae7
--- /dev/null
+++ b/WordPress/Classes/Models/Blocking/BlockedAuthor.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+@objc(BlockedAuthor)
+final class BlockedAuthor: NSManagedObject {
+
+ @NSManaged var accountID: NSNumber
+ @NSManaged var authorID: NSNumber
+}
+
+extension BlockedAuthor {
+
+ // MARK: Fetch Elements
+
+ static func findOne(_ query: Query, context: NSManagedObjectContext) -> BlockedAuthor? {
+ return Self.find(query, context: context).first
+ }
+
+ static func find(_ query: Query, context: NSManagedObjectContext) -> [BlockedAuthor] {
+ do {
+ let request = NSFetchRequest(entityName: Self.entityName())
+ request.predicate = query.predicate
+ let result = try context.fetch(request)
+ return result
+ } catch let error {
+ DDLogError("Couldn't fetch blocked author with error: \(error.localizedDescription)")
+ return []
+ }
+ }
+
+ // MARK: Inserting Elements
+
+ static func insert(into context: NSManagedObjectContext) -> BlockedAuthor {
+ return NSEntityDescription.insertNewObject(forEntityName: Self.entityName(), into: context) as! BlockedAuthor
+ }
+
+ // MARK: - Deleting Elements
+
+ @discardableResult
+ static func delete(_ query: Query, context: NSManagedObjectContext) -> Bool {
+ let objects = Self.find(query, context: context)
+ for object in objects {
+ context.deleteObject(object)
+ }
+ return true
+ }
+
+ // MARK: - Types
+
+ enum Query {
+ case accountID(NSNumber)
+ case predicate(NSPredicate)
+
+ var predicate: NSPredicate {
+ switch self {
+ case .accountID(let id):
+ return NSPredicate(format: "\(#keyPath(BlockedAuthor.accountID)) = %@", id)
+ case .predicate(let predicate):
+ return predicate
+ }
+ }
+ }
+}
diff --git a/WordPress/Classes/Models/Blocking/BlockedSite.swift b/WordPress/Classes/Models/Blocking/BlockedSite.swift
new file mode 100644
index 000000000000..b26979cee24e
--- /dev/null
+++ b/WordPress/Classes/Models/Blocking/BlockedSite.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+@objc(BlockedSite)
+final class BlockedSite: NSManagedObject {
+
+ @NSManaged var accountID: NSNumber
+ @NSManaged var blogID: NSNumber
+}
+
+extension BlockedSite {
+
+ // MARK: Fetch Elements
+
+ static func findOne(accountID: NSNumber, blogID: NSNumber, context: NSManagedObjectContext) -> BlockedSite? {
+ return Self.find(accountID: accountID, blogID: blogID, context: context).first
+ }
+
+ static func find(accountID: NSNumber, blogID: NSNumber, context: NSManagedObjectContext) -> [BlockedSite] {
+ do {
+ let request = NSFetchRequest(entityName: Self.entityName())
+ request.fetchLimit = 1
+ request.predicate = NSPredicate(format: "\(#keyPath(BlockedSite.accountID)) = %@ AND \(#keyPath(BlockedSite.blogID)) = %@", accountID, blogID)
+ let result = try context.fetch(request)
+ return result
+ } catch let error {
+ DDLogError("Couldn't fetch blocked site with error: \(error.localizedDescription)")
+ return []
+ }
+ }
+
+ // MARK: Inserting Elements
+
+ static func insert(into context: NSManagedObjectContext) -> BlockedSite {
+ return NSEntityDescription.insertNewObject(forEntityName: Self.entityName(), into: context) as! BlockedSite
+ }
+
+ // MARK: - Deleting Elements
+
+ @discardableResult
+ static func delete(accountID: NSNumber, blogID: NSNumber, context: NSManagedObjectContext) -> Bool {
+ let objects = Self.find(accountID: accountID, blogID: blogID, context: context)
+ for object in objects {
+ context.deleteObject(object)
+ }
+ return true
+ }
+}
diff --git a/WordPress/Classes/Models/Blog+BlockEditorSettings.swift b/WordPress/Classes/Models/Blog+BlockEditorSettings.swift
new file mode 100644
index 000000000000..11ccf22a2281
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+BlockEditorSettings.swift
@@ -0,0 +1,15 @@
+import Foundation
+import CoreData
+
+extension Blog {
+
+ /// Stores the relationship to the `BlockEditorSettings` which is an optional entity that holds settings realated to the BlockEditor. These are features
+ /// such as Global Styles and Full Site Editing settings and capabilities.
+ ///
+ @NSManaged public var blockEditorSettings: BlockEditorSettings?
+
+ @objc
+ func supportsBlockEditorSettings() -> Bool {
+ return hasRequiredWordPressVersion("5.8")
+ }
+}
diff --git a/WordPress/Classes/Models/Blog+BlogAuthors.swift b/WordPress/Classes/Models/Blog+BlogAuthors.swift
index 2340433cfd3d..8545eacf0207 100644
--- a/WordPress/Classes/Models/Blog+BlogAuthors.swift
+++ b/WordPress/Classes/Models/Blog+BlogAuthors.swift
@@ -3,7 +3,7 @@ import CoreData
extension Blog {
- @NSManaged public var authors: NSSet?
+ @NSManaged public var authors: Set?
@objc(addAuthorsObject:)
@@ -17,4 +17,14 @@ extension Blog {
@objc(removeAuthors:)
@NSManaged public func removeFromAuthors(_ values: NSSet)
+
+ @objc
+ func getAuthorWith(id: NSNumber) -> BlogAuthor? {
+ return authors?.first(where: { $0.userID == id })
+ }
+
+ @objc
+ func getAuthorWith(linkedID: NSNumber) -> BlogAuthor? {
+ return authors?.first(where: { $0.linkedUserID == linkedID })
+ }
}
diff --git a/WordPress/Classes/Models/Blog+Capabilities.swift b/WordPress/Classes/Models/Blog+Capabilities.swift
index 6bcc4486d836..a493d813a354 100644
--- a/WordPress/Classes/Models/Blog+Capabilities.swift
+++ b/WordPress/Classes/Models/Blog+Capabilities.swift
@@ -28,7 +28,7 @@ extension Blog {
/// Returns true if a given capability is enabled. False otherwise
///
public func isUserCapableOf(_ capability: Capability) -> Bool {
- return capabilities?[capability.rawValue] as? Bool ?? false
+ return isUserCapableOf(capability.rawValue)
}
/// Returns true if the current user is allowed to list a Blog's Users
@@ -48,4 +48,36 @@ extension Blog {
@objc public func isUploadingFilesAllowed() -> Bool {
return isUserCapableOf(.UploadFiles)
}
+
+ /// Returns true if the current user is allowed to see Jetpack's Backups
+ ///
+ @objc public func isBackupsAllowed() -> Bool {
+ return isUserCapableOf("backup") || isUserCapableOf("backup-daily") || isUserCapableOf("backup-realtime")
+ }
+
+ /// Returns true if the current user is allowed to see Jetpack's Scan
+ ///
+ @objc public func isScanAllowed() -> Bool {
+ return !hasBusinessPlan && isUserCapableOf("scan")
+ }
+
+ /// Returns true if the current user is allowed to list and edit the blog's Pages
+ ///
+ @objc public func isListingPagesAllowed() -> Bool {
+ return isAdmin || isUserCapableOf(.EditPages)
+ }
+
+ /// Returns true if the current user is allowed to view Stats
+ ///
+ @objc public func isViewingStatsAllowed() -> Bool {
+ return isAdmin || isUserCapableOf(.ViewStats)
+ }
+
+ private func isUserCapableOf(_ capability: String) -> Bool {
+ return capabilities?[capability] as? Bool ?? false
+ }
+
+ public func areBloggingRemindersAllowed() -> Bool {
+ return Feature.enabled(.bloggingReminders) && isUserCapableOf(.EditPosts) && JetpackNotificationMigrationService.shared.shouldPresentNotifications()
+ }
}
diff --git a/WordPress/Classes/Models/Blog+Creation.swift b/WordPress/Classes/Models/Blog+Creation.swift
new file mode 100644
index 000000000000..b5346d6e64e6
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+Creation.swift
@@ -0,0 +1,29 @@
+extension Blog {
+
+ /// Creates a blank `Blog` object for this account
+ @objc(createBlankBlogWithAccount:)
+ static func createBlankBlog(with account: WPAccount) -> Blog {
+ let blog = createBlankBlog(in: account.managedObjectContext!)
+ blog.account = account
+ return blog
+ }
+
+ /// Creates a blank `Blog` object with no account
+ @objc(createBlankBlogInContext:)
+ static func createBlankBlog(in context: NSManagedObjectContext) -> Blog {
+ let blog = Blog(context: context)
+ blog.addSettingsIfNecessary()
+ return blog
+ }
+
+ @objc
+ func addSettingsIfNecessary() {
+ guard settings == nil else {
+ return
+ }
+
+ settings = BlogSettings(context: managedObjectContext!)
+ settings?.blog = self
+ }
+
+}
diff --git a/WordPress/Classes/Models/Blog+DashboardState.swift b/WordPress/Classes/Models/Blog+DashboardState.swift
new file mode 100644
index 000000000000..be8f4807b21f
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+DashboardState.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+extension Blog {
+ /// The state of the dashboard for the current blog
+ var dashboardState: BlogDashboardState {
+ BlogDashboardState.shared(for: self)
+ }
+}
diff --git a/WordPress/Classes/Models/Blog+History.swift b/WordPress/Classes/Models/Blog+History.swift
new file mode 100644
index 000000000000..f0b043b642a6
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+History.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+extension Blog {
+
+ /// Returns the blog currently flagged as the one last used, or the primary blog,
+ /// or the first blog in an alphanumerically sorted list, whichever is found first.
+ @objc(lastUsedOrFirstInContext:)
+ static func lastUsedOrFirst(in context: NSManagedObjectContext) -> Blog? {
+ lastUsed(in: context)
+ ?? (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.defaultBlog
+ ?? firstBlog(in: context)
+ }
+
+ /// Returns the blog currently flaged as the one last used.
+ static func lastUsed(in context: NSManagedObjectContext) -> Blog? {
+ guard let url = RecentSitesService().recentSites.first else {
+ return nil
+ }
+
+ return try? BlogQuery()
+ .visible(true)
+ .hostname(matching: url)
+ .blog(in: context)
+ }
+
+ private static func firstBlog(in context: NSManagedObjectContext) -> Blog? {
+ try? BlogQuery()
+ .visible(true)
+ .blog(in: context)
+ }
+
+}
diff --git a/WordPress/Classes/Models/Blog+HomepageSettings.swift b/WordPress/Classes/Models/Blog+HomepageSettings.swift
new file mode 100644
index 000000000000..59895499f4c1
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+HomepageSettings.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+enum HomepageType: String {
+ case page
+ case posts
+
+ var title: String {
+ switch self {
+ case .page:
+ return NSLocalizedString("Static Homepage", comment: "Name of setting configured when a site uses a static page as its homepage")
+ case .posts:
+ return NSLocalizedString("Classic Blog", comment: "Name of setting configured when a site uses a list of blog posts as its homepage")
+ }
+ }
+
+ var remoteType: RemoteHomepageType {
+ switch self {
+ case .page:
+ return .page
+ case .posts:
+ return .posts
+ }
+ }
+}
+
+extension Blog {
+ private enum OptionsKeys {
+ static let homepageType = "show_on_front"
+ static let homepageID = "page_on_front"
+ static let postsPageID = "page_for_posts"
+ }
+
+ /// The type of homepage used for the site: blog posts, or static pages
+ ///
+ var homepageType: HomepageType? {
+ get {
+ guard let options = options,
+ !options.isEmpty,
+ let type = getOptionString(name: OptionsKeys.homepageType)
+ else {
+ return nil
+ }
+
+ return HomepageType(rawValue: type)
+ }
+ set {
+ if let value = newValue?.rawValue {
+ setValue(value, forOption: OptionsKeys.homepageType)
+ }
+ }
+ }
+
+ /// The ID of the page to use for the site's 'posts' page,
+ /// if `homepageType` is set to `.posts`
+ ///
+ var homepagePostsPageID: Int? {
+ get {
+ guard let options = options,
+ !options.isEmpty,
+ let pageID = getOptionNumeric(name: OptionsKeys.postsPageID)
+ else {
+ return nil
+ }
+
+ return pageID.intValue
+ }
+ set {
+ let number: NSNumber?
+ if let newValue = newValue {
+ number = NSNumber(integerLiteral: newValue)
+ } else {
+ number = nil
+ }
+ setValue(number as Any, forOption: OptionsKeys.postsPageID)
+ }
+ }
+
+ /// The ID of the page to use for the site's homepage,
+ /// if `homepageType` is set to `.page`
+ ///
+ var homepagePageID: Int? {
+ get {
+ guard let options = options,
+ !options.isEmpty,
+ let pageID = getOptionNumeric(name: OptionsKeys.homepageID)
+ else {
+ return nil
+ }
+
+ return pageID.intValue
+ }
+ set {
+ let number: NSNumber?
+ if let newValue = newValue {
+ number = NSNumber(integerLiteral: newValue)
+ } else {
+ number = nil
+ }
+ setValue(number as Any, forOption: OptionsKeys.homepageID)
+ }
+ }
+
+ /// Getter which returns the current homepage (or nil)
+ /// Note: It seems to be necessary to first sync pages (otherwise the `findPost` result fails to cast to `Page`)
+ var homepage: Page? {
+ guard let pageID = homepageType == .page ? homepagePageID
+ : homepageType == .posts ? homepagePostsPageID
+ : nil else { return nil }
+ let context = ContextManager.sharedInstance().mainContext
+ return lookupPost(withID: Int64(pageID), in: context) as? Page
+ }
+}
diff --git a/WordPress/Classes/Models/Blog+Jetpack.swift b/WordPress/Classes/Models/Blog+Jetpack.swift
index 3e7a83ad34b5..d96a7a07bb37 100644
--- a/WordPress/Classes/Models/Blog+Jetpack.swift
+++ b/WordPress/Classes/Models/Blog+Jetpack.swift
@@ -3,11 +3,11 @@ extension Blog {
return getOptionValue(name) as? T
}
- private func getOptionString(name: String) -> String? {
+ func getOptionString(name: String) -> String? {
return (getOption(name: name) as NSString?).map(String.init)
}
- private func getOptionNumeric(name: String) -> NSNumber? {
+ func getOptionNumeric(name: String) -> NSNumber? {
switch getOptionValue(name) {
case let numericValue as NSNumber:
return numericValue
@@ -31,4 +31,53 @@ extension Blog {
state.automatedTransfer = getOption(name: "is_automated_transfer") ?? false
return state
}
+
+ /// Returns true if the blog has the proper version of Jetpack installed,
+ /// otherwise false
+ ///
+ var hasJetpack: Bool {
+ guard let jetpack else {
+ return false
+ }
+ return (jetpack.isConnected && jetpack.isUpdatedToRequiredVersion)
+ }
+
+ /// Returns true if the blog has a version of the Jetpack plugin installed,
+ /// otherwise false
+ ///
+ var jetpackIsConnected: Bool {
+ guard let jetpack else {
+ return false
+ }
+ return jetpack.isConnected
+ }
+
+ // MARK: Jetpack Individual Plugins Support
+
+ var jetpackConnectionActivePlugins: [String]? {
+ switch getOptionValue("jetpack_connection_active_plugins") {
+ case .some(let values as [NSString]):
+ return values.map { String($0) }
+ case .some(let values as [String]):
+ return values
+ default:
+ return nil
+ }
+ }
+
+ /// Returns true if the blog is Jetpack-connected only through individual plugins. Otherwise false.
+ ///
+ /// If the site is hosted at WP.com, the key `jetpack_connection_active_plugins` will not exist in `options`.
+ /// Atomic sites will have the full Jetpack plugin automatically installed.
+ /// Example values for Jetpack individual plugins: `jetpack-search`, `jetpack-backup`, etc.
+ ///
+ /// Note: We can't use `jetpackIsConnected` because it checks the installed Jetpack version.
+ ///
+ var jetpackIsConnectedWithoutFullPlugin: Bool {
+ guard let activeJetpackPlugins = jetpackConnectionActivePlugins else {
+ return false
+ }
+
+ return !(activeJetpackPlugins.isEmpty || activeJetpackPlugins.contains("jetpack"))
+ }
}
diff --git a/WordPress/Classes/Models/Blog+Lookup.swift b/WordPress/Classes/Models/Blog+Lookup.swift
new file mode 100644
index 000000000000..dbc57cc3ad6f
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+Lookup.swift
@@ -0,0 +1,154 @@
+import Foundation
+
+/// An extension dedicated to looking up and returning blog objects
+public extension Blog {
+
+ /// Lookup a Blog by ID
+ ///
+ /// - Parameters:
+ /// - id: The ID associated with the blog.
+ ///
+ /// On a WPMU site, this is the `blog_id` field on the [the wp_blogs table](https://codex.wordpress.org/Database_Description#Table:_wp_blogs).
+ /// - context: An NSManagedObjectContext containing the `Blog` object with the given `blogID`.
+ /// - Returns: The `Blog` object associated with the given `blogID`, if it exists.
+ static func lookup(withID id: Int, in context: NSManagedObjectContext) throws -> Blog? {
+ return try lookup(withID: Int64(id), in: context)
+ }
+
+ /// Lookup a Blog by ID
+ ///
+ /// - Parameters:
+ /// - id: The ID associated with the blog.
+ ///
+ /// On a WPMU site, this is the `blog_id` field on the [the wp_blogs table](https://codex.wordpress.org/Database_Description#Table:_wp_blogs).
+ /// - context: An NSManagedObjectContext containing the `Blog` object with the given `blogID`.
+ /// - Returns: The `Blog` object associated with the given `blogID`, if it exists.
+ static func lookup(withID id: Int64, in context: NSManagedObjectContext) throws -> Blog? {
+ let fetchRequest = NSFetchRequest(entityName: Blog.entityName())
+ fetchRequest.predicate = NSPredicate(format: "blogID == %ld", id)
+ return try context.fetch(fetchRequest).first
+ }
+
+ /// Lookup a Blog by ID
+ ///
+ /// - Parameters:
+ /// - id: The NSNumber-wrapped ID associated with the blog.
+ ///
+ /// On a WPMU site, this is the `blog_id` field on the [the wp_blogs table](https://codex.wordpress.org/Database_Description#Table:_wp_blogs).
+ /// - context: An NSManagedObjectContext containing the `Blog` object with the given `blogID`.
+ /// - Returns: The `Blog` object associated with the given `blogID`, if it exists.
+ @objc
+ static func lookup(withID id: NSNumber, in context: NSManagedObjectContext) -> Blog? {
+ // Because a `nil` NSNumber can be passed from Objective-C, we can't trust the object
+ // to have a valid value. For that reason, we'll unwrap it to an `int64` and look that up instead.
+ // That way, if the `id` is `nil`, it'll return nil instead of crashing while trying to
+ // assemble the predicate as in `NSPredicate("blogID == %@")`
+ try? lookup(withID: id.int64Value, in: context)
+ }
+
+ /// Lookup a Blog by its hostname
+ ///
+ /// - Parameters:
+ /// - hostname: The hostname of the blog.
+ /// - context: An `NSManagedObjectContext` containing the `Blog` object with the given `hostname`.
+ /// - Returns: The `Blog` object associated with the given `hostname`, if it exists.
+ @objc(lookupWithHostname:inContext:)
+ static func lookup(hostname: String, in context: NSManagedObjectContext) -> Blog? {
+ try? BlogQuery().hostname(containing: hostname).blog(in: context)
+ }
+
+ /// Lookup a Blog by WP.ORG Credentials
+ ///
+ /// - Parameters:
+ /// - username: The username associated with the blog.
+ /// - xmlrpc: The xmlrpc URL address
+ /// - context: An NSManagedObjectContext containing the `Blog` object with the given `blogID`.
+ /// - Returns: The `Blog` object associated with the given `username` and `xmlrpc`, if it exists.
+ @objc(lookupWithUsername:xmlrpc:inContext:)
+ static func lookup(username: String, xmlrpc: String, in context: NSManagedObjectContext) -> Blog? {
+ try? BlogQuery().xmlrpc(matching: xmlrpc).selfHostedBlogUsername(username).blog(in: context)
+ }
+
+ /// Searches for a `Blog` object for this account with the given XML-RPC endpoint
+ ///
+ /// - Warning: If more than one blog is found, they'll be considered duplicates and be
+ /// deleted leaving only one of them.
+ ///
+ /// - Parameters:
+ /// - xmlrpc: the XML-RPC endpoint URL as a string
+ /// - account: the account the blog belongs to
+ /// - context: the NSManagedObjectContext containing the account and the Blog object.
+ /// - Returns: the blog if one was found, otherwise it returns nil
+ static func lookup(xmlrpc: String, andRemoveDuplicateBlogsOf account: WPAccount, in context: NSManagedObjectContext) -> Blog? {
+ let predicate = NSPredicate(format: "xmlrpc like %@", xmlrpc)
+ let foundBlogs = account.blogs.filter { predicate.evaluate(with: $0) }
+
+ guard foundBlogs.count > 1 else {
+ return foundBlogs.first
+ }
+
+ // If more than one blog matches, return the first and delete the rest
+
+ // Choose blogs with URL not starting with https to account for a glitch in the API in early 2014
+ let blogToReturn = foundBlogs.first { $0.url?.starts(with: "https://") == false }
+ ?? foundBlogs.randomElement()!
+
+ // Remove the duplicates
+ var duplicates = foundBlogs
+ duplicates.remove(blogToReturn)
+ duplicates.forEach(context.delete(_:))
+
+ return blogToReturn
+ }
+
+ @objc(countInContext:)
+ static func count(in context: NSManagedObjectContext) -> Int {
+ BlogQuery().count(in: context)
+ }
+
+ @objc(wpComBlogCountInContext:)
+ static func wpComBlogCount(in context: NSManagedObjectContext) -> Int {
+ BlogQuery().hostedByWPCom(true).count(in: context)
+ }
+
+ static func hasAnyJetpackBlogs(in context: NSManagedObjectContext) throws -> Bool {
+ let fetchRequest = NSFetchRequest(entityName: Blog.entityName())
+ fetchRequest.predicate = NSPredicate(format: "account != NULL AND isHostedAtWPcom = NO")
+ if try context.count(for: fetchRequest) > 0 {
+ return true
+ }
+
+ return Blog.selfHosted(in: context)
+ .filter { $0.jetpack?.isConnected == true }
+ .count > 0
+ }
+
+ @available(swift, obsoleted: 1.0)
+ @objc(hasAnyJetpackBlogsInContext:)
+ static func objc_hasAnyJetpackBlogs(in context: NSManagedObjectContext) -> Bool {
+ (try? hasAnyJetpackBlogs(in: context)) == true
+ }
+
+ @objc(selfHostedInContext:)
+ static func selfHosted(in context: NSManagedObjectContext) -> [Blog] {
+ (try? BlogQuery().hostedByWPCom(false).blogs(in: context)) ?? []
+ }
+
+ /// Find a cached comment with given ID.
+ ///
+ /// - Parameter id: The comment id
+ /// - Returns: The `Comment` object associated with the given id, or `nil` if none is found.
+ @objc
+ func comment(withID id: NSNumber) -> Comment? {
+ comment(withID: id.int32Value)
+ }
+
+ /// Find a cached comment with given ID.
+ ///
+ /// - Parameter id: The comment id
+ /// - Returns: The `Comment` object associated with the given id, or `nil` if none is found.
+ func comment(withID id: Int32) -> Comment? {
+ (comments as? Set)?.first { $0.commentID == id }
+ }
+
+}
diff --git a/WordPress/Classes/Models/Blog+Media.swift b/WordPress/Classes/Models/Blog+Media.swift
new file mode 100644
index 000000000000..f0aae5188094
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+Media.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+extension Blog {
+
+ /// Get the number of items in a blog media library that are of a certain type.
+ ///
+ /// - Parameter mediaTypes: set of media type values to be considered in the counting.
+ /// - Returns: Number of media assets matching the criteria.
+ @objc(mediaLibraryCountForTypes:)
+ func mediaLibraryCount(types mediaTypes: NSSet) -> Int {
+ guard let context = managedObjectContext else {
+ return 0
+ }
+
+ var count = 0
+ context.performAndWait {
+ var predicate = NSPredicate(format: "blog == %@", self)
+
+ if mediaTypes.count > 0 {
+ let types = mediaTypes
+ .map { obj in
+ guard let rawValue = (obj as? NSNumber)?.uintValue,
+ let type = MediaType(rawValue: rawValue) else {
+ fatalError("Can't convert \(obj) to MediaType")
+ }
+ return Media.string(from: type)
+ }
+ let filterPredicate = NSPredicate(format: "mediaTypeString IN %@", types)
+ predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, filterPredicate])
+ }
+
+ count = context.countObjects(ofType: Media.self, matching: predicate)
+ }
+ return count
+ }
+
+}
diff --git a/WordPress/Classes/Models/Blog+MySite.swift b/WordPress/Classes/Models/Blog+MySite.swift
new file mode 100644
index 000000000000..f1795cb95592
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+MySite.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+extension Blog {
+ /// If the blog should show the "Jetpack" or the "General" section
+ @objc var shouldShowJetpackSection: Bool {
+ (supports(.activity) && !isWPForTeams()) || supports(.jetpackSettings)
+ }
+}
diff --git a/WordPress/Classes/Models/Blog+Organization.swift b/WordPress/Classes/Models/Blog+Organization.swift
new file mode 100644
index 000000000000..3bf4eeedee47
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+Organization.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+extension Blog {
+ var isAutomatticP2: Bool {
+ SiteOrganizationType(rawValue: organizationID.intValue) == .automattic
+ }
+}
diff --git a/WordPress/Classes/Models/Blog+Post.swift b/WordPress/Classes/Models/Blog+Post.swift
new file mode 100644
index 000000000000..e29bdd8d4f31
--- /dev/null
+++ b/WordPress/Classes/Models/Blog+Post.swift
@@ -0,0 +1,111 @@
+import Foundation
+
+// MARK: - Lookup posts
+
+extension Blog {
+ /// Lookup a post in the blog.
+ ///
+ /// - Parameter postID: The ID associated with the post.
+ /// - Returns: The `AbstractPost` associated with the given post ID.
+ @objc(lookupPostWithID:inContext:)
+ func lookupPost(withID postID: NSNumber, in context: NSManagedObjectContext) -> AbstractPost? {
+ lookupPost(withID: postID.int64Value, in: context)
+ }
+
+ /// Lookup a post in the blog.
+ ///
+ /// - Parameter postID: The ID associated with the post.
+ /// - Returns: The `AbstractPost` associated with the given post ID.
+ func lookupPost(withID postID: Int, in context: NSManagedObjectContext) -> AbstractPost? {
+ lookupPost(withID: Int64(postID), in: context)
+ }
+
+ /// Lookup a post in the blog.
+ ///
+ /// - Parameter postID: The ID associated with the post.
+ /// - Returns: The `AbstractPost` associated with the given post ID.
+ func lookupPost(withID postID: Int64, in context: NSManagedObjectContext) -> AbstractPost? {
+ let request = NSFetchRequest(entityName: NSStringFromClass(AbstractPost.self))
+ request.predicate = NSPredicate(format: "blog = %@ AND original = NULL AND postID = %ld", self, postID)
+ return (try? context.fetch(request))?.first
+ }
+}
+
+// MARK: - Create posts
+
+extension Blog {
+
+ /// Create a post in the blog.
+ @objc
+ func createPost() -> Post {
+ guard let context = managedObjectContext else {
+ fatalError("The `Blog` instance is not associated with an `NSManagedObjectContext`")
+ }
+
+ let post = NSEntityDescription.insertNewObject(forEntityName: NSStringFromClass(Post.self), into: context) as! Post
+ post.blog = self
+ post.remoteStatus = .sync
+
+ if let categoryID = settings?.defaultCategoryID,
+ categoryID.intValue != PostCategoryUncategorized,
+ let category = try? PostCategory.lookup(withBlogID: objectID, categoryID: categoryID, in: context) {
+ post.addCategoriesObject(category)
+ }
+
+ post.postFormat = settings?.defaultPostFormat
+ post.postType = Post.typeDefaultIdentifier
+
+ if let userID = userID, let author = getAuthorWith(id: userID) {
+ post.authorID = author.userID
+ post.author = author.displayName
+ }
+
+ try? context.obtainPermanentIDs(for: [post])
+ precondition(!post.objectID.isTemporaryID, "The new post for this blog must have a permanent ObjectID")
+
+ return post
+ }
+
+ /// Create a draft post in the blog.
+ func createDraftPost() -> Post {
+ let post = createPost()
+ markAsDraft(post)
+ return post
+ }
+
+ /// Create a page in the blog.
+ @objc
+ func createPage() -> Page {
+ guard let context = managedObjectContext else {
+ fatalError("The `Blog` instance is not associated with a `NSManagedObjectContext`")
+ }
+
+ let page = NSEntityDescription.insertNewObject(forEntityName: NSStringFromClass(Page.self), into: context) as! Page
+ page.blog = self
+ page.date_created_gmt = Date()
+ page.remoteStatus = .sync
+
+ if let userID = userID, let author = getAuthorWith(id: userID) {
+ page.authorID = author.userID
+ page.author = author.displayName
+ }
+
+ try? context.obtainPermanentIDs(for: [page])
+ precondition(!page.objectID.isTemporaryID, "The new page for this blog must have a permanent ObjectID")
+
+ return page
+ }
+
+ /// Create a draft page in the blog.
+ func createDraftPage() -> Page {
+ let page = createPage()
+ markAsDraft(page)
+ return page
+ }
+
+ private func markAsDraft(_ post: AbstractPost) {
+ post.remoteStatus = .local
+ post.dateModified = Date()
+ post.status = .draft
+ }
+}
diff --git a/WordPress/Classes/Models/Blog+QuickStart.swift b/WordPress/Classes/Models/Blog+QuickStart.swift
index 44e2cfefa14b..df7e7b506642 100644
--- a/WordPress/Classes/Models/Blog+QuickStart.swift
+++ b/WordPress/Classes/Models/Blog+QuickStart.swift
@@ -9,6 +9,22 @@ extension Blog {
return quickStartTours?.filter { $0.skipped }
}
+ var quickStartType: QuickStartType {
+ get {
+ guard let value = quickStartTypeValue?.intValue,
+ let type = QuickStartType(rawValue: value) else {
+ return .undefined
+ }
+ return type
+ }
+
+ set {
+ quickStartTypeValue = NSNumber(value: newValue.rawValue)
+ let context = managedObjectContext ?? ContextManager.sharedInstance().mainContext
+ ContextManager.sharedInstance().saveContextAndWait(context)
+ }
+ }
+
public func skipTour(_ tourID: String) {
let tourState = findOrCreate(tour: tourID)
tourState.skipped = true
diff --git a/WordPress/Classes/Models/Blog.h b/WordPress/Classes/Models/Blog.h
index 1fb5bbc383e2..1128318f87a8 100644
--- a/WordPress/Classes/Models/Blog.h
+++ b/WordPress/Classes/Models/Blog.h
@@ -8,9 +8,14 @@ NS_ASSUME_NONNULL_BEGIN
@class BlogSettings;
@class WPAccount;
@class WordPressComRestApi;
+@class WordPressOrgRestApi;
@class WordPressOrgXMLRPCApi;
@class Role;
@class QuickStartTourState;
+@class UserSuggestion;
+@class SiteSuggestion;
+@class PageTemplateCategory;
+@class JetpackFeaturesRemovalCoordinator;
extern NSString * const BlogEntityName;
extern NSString * const PostFormatStandard;
@@ -34,6 +39,8 @@ typedef NS_ENUM(NSUInteger, BlogFeature) {
BlogFeatureActivity,
/// Does the blog support mentions?
BlogFeatureMentions,
+ /// Does the blog support xposts?
+ BlogFeatureXposts,
/// Does the blog support push notifications?
BlogFeaturePushNotifications,
/// Does the blog support theme browsing?
@@ -69,7 +76,37 @@ typedef NS_ENUM(NSUInteger, BlogFeature) {
/// Does the blog support deleting media?
BlogFeatureMediaDeletion,
/// Does the blog support Stock Photos feature (free photos library)
- BlogFeatureStockPhotos
+ BlogFeatureStockPhotos,
+ /// Does the blog support Tenor feature (free GIF library)
+ BlogFeatureTenor,
+ /// Does the blog support setting the homepage type and pages?
+ BlogFeatureHomepageSettings,
+ /// Does the blog support stories?
+ BlogFeatureStories,
+ /// Does the blog support Jetpack contact info block?
+ BlogFeatureContactInfo,
+ BlogFeatureBlockEditorSettings,
+ /// Does the blog support the Layout grid block?
+ BlogFeatureLayoutGrid,
+ /// Does the blog support the tiled gallery block?
+ BlogFeatureTiledGallery,
+ /// Does the blog support the VideoPress block?
+ BlogFeatureVideoPress,
+ /// Does the blog support Facebook embed block?
+ BlogFeatureFacebookEmbed,
+ /// Does the blog support Instagram embed block?
+ BlogFeatureInstagramEmbed,
+ /// Does the blog support Loom embed block?
+ BlogFeatureLoomEmbed,
+ /// Does the blog support Smartframe embed block?
+ BlogFeatureSmartframeEmbed,
+ /// Does the blog support File Downloads section in stats?
+ BlogFeatureFileDownloadsStats,
+ /// Does the blog support Blaze?
+ BlogFeatureBlaze,
+ /// Does the blog support listing and editing Pages?
+ BlogFeaturePages,
+
};
typedef NS_ENUM(NSInteger, SiteVisibility) {
@@ -85,6 +122,7 @@ typedef NS_ENUM(NSInteger, SiteVisibility) {
@property (nonatomic, strong, readwrite, nullable) NSNumber *dotComID;
@property (nonatomic, strong, readwrite, nullable) NSString *xmlrpc;
@property (nonatomic, strong, readwrite, nullable) NSString *apiKey;
+@property (nonatomic, strong, readwrite, nonnull) NSNumber *organizationID;
@property (nonatomic, strong, readwrite, nullable) NSNumber *hasOlderPosts;
@property (nonatomic, strong, readwrite, nullable) NSNumber *hasOlderPages;
@property (nonatomic, strong, readwrite, nullable) NSSet *posts;
@@ -92,9 +130,12 @@ typedef NS_ENUM(NSInteger, SiteVisibility) {
@property (nonatomic, strong, readwrite, nullable) NSSet *tags;
@property (nonatomic, strong, readwrite, nullable) NSSet *comments;
@property (nonatomic, strong, readwrite, nullable) NSSet *connections;
+@property (nonatomic, strong, readwrite, nullable) NSSet *inviteLinks;
@property (nonatomic, strong, readwrite, nullable) NSSet *domains;
@property (nonatomic, strong, readwrite, nullable) NSSet *themes;
@property (nonatomic, strong, readwrite, nullable) NSSet *media;
+@property (nonatomic, strong, readwrite, nullable) NSSet *userSuggestions;
+@property (nonatomic, strong, readwrite, nullable) NSSet *siteSuggestions;
@property (nonatomic, strong, readwrite, nullable) NSOrderedSet *menus;
@property (nonatomic, strong, readwrite, nullable) NSOrderedSet *menuLocations;
@property (nonatomic, strong, readwrite, nullable) NSSet *roles;
@@ -126,12 +167,14 @@ typedef NS_ENUM(NSInteger, SiteVisibility) {
@property (nonatomic, strong, readwrite, nullable) NSSet *sharingButtons;
@property (nonatomic, strong, readwrite, nullable) NSDictionary *capabilities;
@property (nonatomic, strong, readwrite, nullable) NSSet *quickStartTours;
+@property (nonatomic, strong, readwrite, nullable) NSNumber *quickStartTypeValue;
+@property (nonatomic, assign, readwrite) BOOL isBlazeApproved;
/// The blog's user ID for the current user
@property (nonatomic, strong, readwrite, nullable) NSNumber *userID;
/// Disk quota for site, this is only available for WP.com sites
@property (nonatomic, strong, readwrite, nullable) NSNumber *quotaSpaceAllowed;
@property (nonatomic, strong, readwrite, nullable) NSNumber *quotaSpaceUsed;
-
+@property (nullable, nonatomic, retain) NSSet *pageTemplateCategories;
/**
* @details Maps to a BlogSettings instance, which contains a collection of the available preferences,
@@ -159,6 +202,7 @@ typedef NS_ENUM(NSInteger, SiteVisibility) {
@property (nonatomic, weak, readonly, nullable) NSArray *sortedConnections;
@property (nonatomic, readonly, nullable) NSArray *sortedRoles;
@property (nonatomic, strong, readonly, nullable) WordPressOrgXMLRPCApi *xmlrpcApi;
+@property (nonatomic, strong, readonly, nullable) WordPressOrgRestApi *wordPressOrgRestApi;
@property (nonatomic, weak, readonly, nullable) NSString *version;
@property (nonatomic, strong, readonly, nullable) NSString *authToken;
@property (nonatomic, strong, readonly, nullable) NSSet *allowedFileTypes;
@@ -185,22 +229,29 @@ typedef NS_ENUM(NSInteger, SiteVisibility) {
// Used to check if the blog has an icon set up
@property (readonly) BOOL hasIcon;
+/** Determine timezone for blog from blog options. If no timezone information is stored on the device, then assume GMT+0 is the default. */
+@property (readonly) NSTimeZone *timeZone;
+
#pragma mark - Blog information
- (BOOL)isAtomic;
+- (BOOL)isWPForTeams;
- (BOOL)isAutomatedTransfer;
- (BOOL)isPrivate;
+- (BOOL)isPrivateAtWPCom;
- (nullable NSArray *)sortedCategories;
- (nullable id)getOptionValue:(NSString *) name;
+- (void)setValue:(id)value forOption:(NSString *)name;
- (NSString *)loginUrl;
- (NSString *)urlWithPath:(NSString *)path;
- (NSString *)adminUrlWithPath:(NSString *)path;
-- (NSUInteger)numberOfPendingComments;
- (NSDictionary *) getImageResizeDimensions;
- (BOOL)supportsFeaturedImages;
- (BOOL)supports:(BlogFeature)feature;
- (BOOL)supportsPublicize;
- (BOOL)supportsShareButtons;
+- (BOOL)isStatsActive;
+- (BOOL)hasMappedDomain;
/**
* Returnst the text description for a post format code
@@ -255,6 +306,10 @@ typedef NS_ENUM(NSInteger, SiteVisibility) {
*/
- (BOOL)isBasicAuthCredentialStored;
+/// Checks the blogs installed WordPress version is more than or equal to the requiredVersion
+/// @param requiredVersion The minimum version to check for
+- (BOOL)hasRequiredWordPressVersion:(NSString *)requiredVersion;
+
@end
NS_ASSUME_NONNULL_END
diff --git a/WordPress/Classes/Models/Blog.m b/WordPress/Classes/Models/Blog.m
index f492c0686f05..d22b633d47db 100644
--- a/WordPress/Classes/Models/Blog.m
+++ b/WordPress/Classes/Models/Blog.m
@@ -1,15 +1,15 @@
#import "Blog.h"
-#import "Comment.h"
#import "WPAccount.h"
#import "AccountService.h"
#import "NSURL+IDN.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "Constants.h"
#import "WordPress-Swift.h"
-#import "SFHFKeychainUtils.h"
#import "WPUserAgent.h"
#import "WordPress-Swift.h"
+@class Comment;
+
static NSInteger const ImageSizeSmallWidth = 240;
static NSInteger const ImageSizeSmallHeight = 180;
static NSInteger const ImageSizeMediumWidth = 480;
@@ -21,16 +21,19 @@
NSString * const BlogEntityName = @"Blog";
NSString * const PostFormatStandard = @"standard";
+NSString * const ActiveModulesKeyStats = @"stats";
NSString * const ActiveModulesKeyPublicize = @"publicize";
NSString * const ActiveModulesKeySharingButtons = @"sharedaddy";
NSString * const OptionsKeyActiveModules = @"active_modules";
NSString * const OptionsKeyPublicizeDisabled = @"publicize_permanently_disabled";
NSString * const OptionsKeyIsAutomatedTransfer = @"is_automated_transfer";
NSString * const OptionsKeyIsAtomic = @"is_wpcom_atomic";
+NSString * const OptionsKeyIsWPForTeams = @"is_wpforteams_site";
@interface Blog ()
@property (nonatomic, strong, readwrite) WordPressOrgXMLRPCApi *xmlrpcApi;
+@property (nonatomic, strong, readwrite) WordPressOrgRestApi *wordPressOrgRestApi;
@end
@@ -41,6 +44,7 @@ @implementation Blog
@dynamic url;
@dynamic xmlrpc;
@dynamic apiKey;
+@dynamic organizationID;
@dynamic hasOlderPosts;
@dynamic hasOlderPages;
@dynamic hasDomainCredit;
@@ -50,8 +54,11 @@ @implementation Blog
@dynamic comments;
@dynamic connections;
@dynamic domains;
+@dynamic inviteLinks;
@dynamic themes;
@dynamic media;
+@dynamic userSuggestions;
+@dynamic siteSuggestions;
@dynamic menus;
@dynamic menuLocations;
@dynamic roles;
@@ -79,15 +86,19 @@ @implementation Blog
@dynamic sharingButtons;
@dynamic capabilities;
@dynamic quickStartTours;
+@dynamic quickStartTypeValue;
+@dynamic isBlazeApproved;
@dynamic userID;
@dynamic quotaSpaceAllowed;
@dynamic quotaSpaceUsed;
+@dynamic pageTemplateCategories;
@synthesize isSyncingPosts;
@synthesize isSyncingPages;
@synthesize videoPressEnabled;
@synthesize isSyncingMedia;
@synthesize xmlrpcApi = _xmlrpcApi;
+@synthesize wordPressOrgRestApi = _wordPressOrgRestApi;
#pragma mark - NSManagedObject subclass methods
@@ -95,7 +106,13 @@ - (void)prepareForDeletion
{
[super prepareForDeletion];
+ // delete stored password in the keychain for self-hosted sites.
+ if ([self.username length] > 0 && [self.xmlrpc length] > 0) {
+ self.password = nil;
+ }
+
[_xmlrpcApi invalidateAndCancelTasks];
+ [_wordPressOrgRestApi invalidateAndCancelTasks];
}
- (void)didTurnIntoFault
@@ -104,45 +121,49 @@ - (void)didTurnIntoFault
// Clean up instance variables
self.xmlrpcApi = nil;
+ self.wordPressOrgRestApi = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
-
#pragma mark -
#pragma mark Custom methods
+- (NSNumber *)organizationID {
+ NSNumber *organizationID = [self primitiveValueForKey:@"organizationID"];
+
+ if (organizationID == nil) {
+ return @0;
+ } else {
+ return organizationID;
+ }
+}
+
- (BOOL)isAtomic
{
- return [self.options[OptionsKeyIsAtomic] boolValue];
+ NSNumber *value = (NSNumber *)[self getOptionValue:OptionsKeyIsAtomic];
+ return [value boolValue];
}
-- (BOOL)isAutomatedTransfer
+- (BOOL)isWPForTeams
{
- NSNumber *value = (NSNumber *)[self getOptionValue:OptionsKeyIsAutomatedTransfer];
+ NSNumber *value = (NSNumber *)[self getOptionValue:OptionsKeyIsWPForTeams];
return [value boolValue];
}
-- (NSString *)icon
+- (BOOL)isAutomatedTransfer
{
- [self willAccessValueForKey:@"icon"];
- NSString *icon = [self primitiveValueForKey:@"icon"];
- [self didAccessValueForKey:@"icon"];
-
- if (icon) {
- return icon;
- }
-
- // if the icon is not set we can use the host url to construct it
- NSString *hostUrl = [[NSURL URLWithString:self.xmlrpc] host];
- if (hostUrl == nil) {
- hostUrl = self.xmlrpc;
- }
- return hostUrl;
+ NSNumber *value = (NSNumber *)[self getOptionValue:OptionsKeyIsAutomatedTransfer];
+ return [value boolValue];
}
// Used as a key to store passwords, if you change the algorithm, logins will break
- (NSString *)displayURL
{
+ if (self.url == nil) {
+ DDLogInfo(@"Blog display URL is nil");
+ return nil;
+ }
+
NSError *error = nil;
NSRegularExpression *protocol = [NSRegularExpression regularExpressionWithPattern:@"http(s?)://" options:NSRegularExpressionCaseInsensitive error:&error];
NSString *result = [NSString stringWithFormat:@"%@", [protocol stringByReplacingMatchesInString:self.url options:0 range:NSMakeRange(0, [self.url length]) withTemplate:@""]];
@@ -206,6 +227,11 @@ - (NSString *)loginUrl
- (NSString *)urlWithPath:(NSString *)path
{
+ if (!path || !self.xmlrpc) {
+ DDLogError(@"Blog: Error creating urlWithPath.");
+ return nil;
+ }
+
NSError *error = nil;
NSRegularExpression *xmlrpc = [NSRegularExpression regularExpressionWithPattern:@"xmlrpc.php$" options:NSRegularExpressionCaseInsensitive error:&error];
return [xmlrpc stringByReplacingMatchesInString:self.xmlrpc options:0 range:NSMakeRange(0, [self.xmlrpc length]) withTemplate:path];
@@ -223,26 +249,6 @@ - (NSString *)adminUrlWithPath:(NSString *)path
return [NSString stringWithFormat:@"%@%@", adminBaseUrl, path];
}
-- (NSUInteger)numberOfPendingComments
-{
- NSUInteger pendingComments = 0;
- if ([self hasFaultForRelationshipNamed:@"comments"]) {
- NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Comment"];
- [request setPredicate:[NSPredicate predicateWithFormat:@"blog = %@ AND status like 'hold'", self]];
- [request setIncludesSubentities:NO];
- NSError *error;
- pendingComments = [self.managedObjectContext countForFetchRequest:request error:&error];
- } else {
- for (Comment *element in self.comments) {
- if ( [CommentStatusPending isEqualToString:element.status] ) {
- pendingComments++;
- }
- }
- }
-
- return pendingComments;
-}
-
- (NSArray *)sortedCategories
{
NSSortDescriptor *sortNameDescriptor = [[NSSortDescriptor alloc] initWithKey:@"categoryName"
@@ -258,17 +264,19 @@ - (NSArray *)sortedPostFormats
if ([self.postFormats count] == 0) {
return @[];
}
+
NSMutableArray *sortedFormats = [NSMutableArray arrayWithCapacity:[self.postFormats count]];
-
+
if (self.postFormats[PostFormatStandard]) {
[sortedFormats addObject:PostFormatStandard];
}
- [self.postFormats enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
- if (![key isEqual:PostFormatStandard]) {
- [sortedFormats addObject:key];
- }
+
+ NSArray *sortedNonStandardFormats = [[self.postFormats keysSortedByValueUsingSelector:@selector(localizedCaseInsensitiveCompare:)] wp_filter:^BOOL(id obj) {
+ return ![obj isEqual:PostFormatStandard];
}];
+ [sortedFormats addObjectsFromArray:sortedNonStandardFormats];
+
return [NSArray arrayWithArray:sortedFormats];
}
@@ -301,6 +309,17 @@ - (NSString *)defaultPostFormatText
return [self postFormatTextFromSlug:self.settings.defaultPostFormat];
}
+- (BOOL)hasMappedDomain {
+ if (![self isHostedAtWPcom]) {
+ return NO;
+ }
+
+ NSURL *unmappedURL = [NSURL URLWithString:[self getOptionValue:@"unmapped_url"]];
+ NSURL *homeURL = [NSURL URLWithString:[self homeURL]];
+
+ return ![[unmappedURL host] isEqualToString:[homeURL host]];
+}
+
- (BOOL)hasIcon
{
// A blog without an icon has the blog url in icon, so we can't directly check its
@@ -308,6 +327,36 @@ - (BOOL)hasIcon
return self.icon.length > 0 ? [NSURL URLWithString:self.icon].pathComponents.count > 1 : NO;
}
+- (NSTimeZone *)timeZone
+{
+ CGFloat const OneHourInSeconds = 60.0 * 60.0;
+
+ NSString *timeZoneName = [self getOptionValue:@"timezone"];
+ NSNumber *gmtOffSet = [self getOptionValue:@"gmt_offset"];
+ id optionValue = [self getOptionValue:@"time_zone"];
+
+ NSTimeZone *timeZone = nil;
+ if (timeZoneName.length > 0) {
+ timeZone = [NSTimeZone timeZoneWithName:timeZoneName];
+ }
+
+ if (!timeZone && gmtOffSet != nil) {
+ timeZone = [NSTimeZone timeZoneForSecondsFromGMT:(gmtOffSet.floatValue * OneHourInSeconds)];
+ }
+
+ if (!timeZone && optionValue != nil) {
+ NSInteger timeZoneOffsetSeconds = [optionValue floatValue] * OneHourInSeconds;
+ timeZone = [NSTimeZone timeZoneForSecondsFromGMT:timeZoneOffsetSeconds];
+ }
+
+ if (!timeZone) {
+ timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0];
+ }
+
+ return timeZone;
+
+}
+
- (NSString *)postFormatTextFromSlug:(NSString *)postFormatSlug
{
NSDictionary *allFormats = self.postFormats;
@@ -322,10 +371,18 @@ - (NSString *)postFormatTextFromSlug:(NSString *)postFormatSlug
return formatText;
}
-// WP.COM private blog.
+/// Call this method to know whether the blog is private.
+///
- (BOOL)isPrivate
{
- return (self.isHostedAtWPcom && [self.settings.privacy isEqualToNumber:@(SiteVisibilityPrivate)]);
+ return [self.settings.privacy isEqualToNumber:@(SiteVisibilityPrivate)];
+}
+
+/// Call this method to know whether the blog is private AND hosted at WP.com.
+///
+- (BOOL)isPrivateAtWPCom
+{
+ return (self.isHostedAtWPcom && [self isPrivate]);
}
- (SiteVisibility)siteVisibility
@@ -395,12 +452,26 @@ - (void)setXmlrpc:(NSString *)xmlrpc
- (NSString *)version
{
- return [self getOptionValue:@"software_version"];
+ // Ensure the value being returned is a string to prevent a crash when using this value in Swift
+ id value = [self getOptionValue:@"software_version"];
+
+ // If its a string, then return its value 🎉
+ if([value isKindOfClass:NSString.class]) {
+ return value;
+ }
+
+ // If its not a string, but can become a string, then convert it
+ if([value respondsToSelector:@selector(stringValue)]) {
+ return [value stringValue];
+ }
+
+ // If the value is an unknown type, and can not become a string, then default to a blank string.
+ return @"";
}
- (NSString *)password
{
- return [SFHFKeychainUtils getPasswordForUsername:self.username andServiceName:self.xmlrpc error:nil];
+ return [SFHFKeychainUtils getPasswordForUsername:self.username andServiceName:self.xmlrpc accessGroup:nil error:nil];
}
- (void)setPassword:(NSString *)password
@@ -411,11 +482,13 @@ - (void)setPassword:(NSString *)password
[SFHFKeychainUtils storeUsername:self.username
andPassword:password
forServiceName:self.xmlrpc
+ accessGroup:nil
updateExisting:YES
error:nil];
} else {
[SFHFKeychainUtils deleteItemForUsername:self.username
andServiceName:self.xmlrpc
+ accessGroup:nil
error:nil];
}
}
@@ -429,7 +502,7 @@ - (NSString *)usernameForSite
{
if (self.username) {
return self.username;
- } else if (self.account && self.isHostedAtWPcom) {
+ } else if (self.account && self.isAccessibleThroughWPCom) {
return self.account.username;
} else {
// FIXME: Figure out how to get the self hosted username when using Jetpack REST (@koke 2015-06-15)
@@ -462,15 +535,21 @@ - (BOOL)supports:(BlogFeature)feature
return [self supportsRestApi] && self.isListingUsersAllowed;
case BlogFeatureWPComRESTAPI:
case BlogFeatureCommentLikes:
+ return [self supportsRestApi];
case BlogFeatureStats:
+ return [self supportsRestApi] && [self isViewingStatsAllowed];
case BlogFeatureStockPhotos:
- return [self supportsRestApi];
+ return [self supportsRestApi] && [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled];
+ case BlogFeatureTenor:
+ return [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled];
case BlogFeatureSharing:
return [self supportsSharing];
case BlogFeatureOAuth2Login:
return [self isHostedAtWPcom];
case BlogFeatureMentions:
- return [self isHostedAtWPcom];
+ return [self isAccessibleThroughWPCom];
+ case BlogFeatureXposts:
+ return [self isAccessibleThroughWPCom];
case BlogFeatureReblog:
case BlogFeaturePlans:
return [self isHostedAtWPcom] && [self isAdmin];
@@ -479,7 +558,7 @@ - (BOOL)supports:(BlogFeature)feature
case BlogFeatureJetpackImageSettings:
return [self supportsJetpackImageSettings];
case BlogFeatureJetpackSettings:
- return [self supportsRestApi] && ![self isHostedAtWPcom] && [self isAdmin];
+ return [self supportsJetpackSettings];
case BlogFeaturePushNotifications:
return [self supportsPushNotifications];
case BlogFeatureThemeBrowsing:
@@ -501,13 +580,41 @@ - (BOOL)supports:(BlogFeature)feature
case BlogFeatureSiteManagement:
return [self supportsSiteManagementServices];
case BlogFeatureDomains:
- return [self isHostedAtWPcom] && [self supportsSiteManagementServices];
+ return ([self isHostedAtWPcom] || [self isAtomic]) && [self isAdmin] && ![self isWPForTeams];
case BlogFeatureNoncePreviews:
return [self supportsRestApi] && ![self isHostedAtWPcom];
case BlogFeatureMediaMetadataEditing:
return [self supportsRestApi] && [self isAdmin];
case BlogFeatureMediaDeletion:
return [self isAdmin];
+ case BlogFeatureHomepageSettings:
+ return [self supportsRestApi] && [self isAdmin];
+ case BlogFeatureStories:
+ return [self supportsStories];
+ case BlogFeatureContactInfo:
+ return [self supportsContactInfo];
+ case BlogFeatureBlockEditorSettings:
+ return [self supportsBlockEditorSettings];
+ case BlogFeatureLayoutGrid:
+ return [self supportsLayoutGrid];
+ case BlogFeatureTiledGallery:
+ return [self supportsTiledGallery];
+ case BlogFeatureVideoPress:
+ return [self supportsVideoPress];
+ case BlogFeatureFacebookEmbed:
+ return [self supportsEmbedVariation: @"9.0"];
+ case BlogFeatureInstagramEmbed:
+ return [self supportsEmbedVariation: @"9.0"];
+ case BlogFeatureLoomEmbed:
+ return [self supportsEmbedVariation: @"9.0"];
+ case BlogFeatureSmartframeEmbed:
+ return [self supportsEmbedVariation: @"10.2"];
+ case BlogFeatureFileDownloadsStats:
+ return [self isHostedAtWPcom];
+ case BlogFeatureBlaze:
+ return [self isBlazeApproved];
+ case BlogFeaturePages:
+ return [self isListingPagesAllowed];
}
}
@@ -554,6 +661,11 @@ - (BOOL)supportsShareButtons
}
}
+- (BOOL)isStatsActive
+{
+ return [self jetpackStatsModuleEnabled] || [self isHostedAtWPcom];
+}
+
- (BOOL)supportsPushNotifications
{
return [self accountIsDefaultAccount];
@@ -569,17 +681,67 @@ - (BOOL)supportsPluginManagement
BOOL hasRequiredJetpack = [self hasRequiredJetpackVersion:@"5.6"];
BOOL isTransferrable = self.isHostedAtWPcom
- && self.hasBusinessPlan
- && self.siteVisibility != SiteVisibilityPrivate
+ && self.hasBusinessPlan
+ && self.siteVisibility != SiteVisibilityPrivate
+ && self.isAdmin;
+
+ BOOL supports = isTransferrable || hasRequiredJetpack;
+
+ // If the site is not hosted on WP.com we can still manage plugins directly using the WP.org rest API
+ // Reference: https://make.wordpress.org/core/2020/07/16/new-and-modified-rest-api-endpoints-in-wordpress-5-5/
+ if(!supports && !self.account){
+ supports = !self.isHostedAtWPcom
+ && self.wordPressOrgRestApi
+ && [self hasRequiredWordPressVersion:@"5.5"]
&& self.isAdmin;
+ }
+
+ return supports;
+}
+
+- (BOOL)supportsStories
+{
+ BOOL hasRequiredJetpack = [self hasRequiredJetpackVersion:@"9.1"];
+ // Stories are disabled in iPad until this Kanvas issue is solved: https://github.com/tumblr/kanvas-ios/issues/104
+ return (hasRequiredJetpack || self.isHostedAtWPcom) && ![UIDevice isPad] && [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled];
+}
+
+- (BOOL)supportsContactInfo
+{
+ return [self hasRequiredJetpackVersion:@"8.5"] || self.isHostedAtWPcom;
+}
+
+- (BOOL)supportsLayoutGrid
+{
+ return self.isHostedAtWPcom || self.isAtomic;
+}
+
+- (BOOL)supportsTiledGallery
+{
+ return self.isHostedAtWPcom;
+}
+
+- (BOOL)supportsVideoPress
+{
+ return self.isHostedAtWPcom;
+}
- return isTransferrable || hasRequiredJetpack;
+- (BOOL)supportsEmbedVariation:(NSString *)requiredJetpackVersion
+{
+ return [self hasRequiredJetpackVersion:requiredJetpackVersion] || self.isHostedAtWPcom;
+}
+
+- (BOOL)supportsJetpackSettings
+{
+ return [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled]
+ && [self supportsRestApi]
+ && ![self isHostedAtWPcom]
+ && [self isAdmin];
}
- (BOOL)accountIsDefaultAccount
{
- AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext];
- return [accountService isDefaultWordPressComAccount:self.account];
+ return [[self account] isDefaultWordPressComAccount];
}
- (NSNumber *)dotComID
@@ -717,6 +879,14 @@ - (WordPressOrgXMLRPCApi *)xmlrpcApi
return _xmlrpcApi;
}
+- (WordPressOrgRestApi *)wordPressOrgRestApi
+{
+ if (_wordPressOrgRestApi == nil) {
+ _wordPressOrgRestApi = [[WordPressOrgRestApi alloc] initWithBlog:self];
+ }
+ return _wordPressOrgRestApi;
+}
+
- (WordPressComRestApi *)wordPressComRestApi
{
if (self.account) {
@@ -743,6 +913,11 @@ - (BOOL)jetpackActiveModule:(NSString *)moduleName
return [activeModules containsObject:moduleName] ?: NO;
}
+- (BOOL)jetpackStatsModuleEnabled
+{
+ return [self jetpackActiveModule:ActiveModulesKeyStats];
+}
+
- (BOOL)jetpackPublicizeModuleEnabled
{
return [self jetpackActiveModule:ActiveModulesKeyPublicize];
@@ -775,6 +950,13 @@ - (BOOL)hasRequiredJetpackVersion:(NSString *)requiredJetpackVersion
&& [self.jetpack.version compare:requiredJetpackVersion options:NSNumericSearch] != NSOrderedAscending;
}
+/// Checks the blogs installed WordPress version is more than or equal to the requiredVersion
+/// @param requiredVersion The minimum version to check for
+- (BOOL)hasRequiredWordPressVersion:(NSString *)requiredVersion
+{
+ return [self.version compare:requiredVersion options:NSNumericSearch] != NSOrderedAscending;
+}
+
#pragma mark - Private Methods
- (id)getOptionValue:(NSString *)name
@@ -790,4 +972,20 @@ - (id)getOptionValue:(NSString *)name
return optionValue;
}
+- (void)setValue:(id)value forOption:(NSString *)name
+{
+ [self.managedObjectContext performBlockAndWait:^{
+ if ( self.options == nil || (self.options.count == 0) ) {
+ return;
+ }
+
+ NSMutableDictionary *mutableOptions = [self.options mutableCopy];
+
+ NSDictionary *valueDict = @{ @"value": value };
+ mutableOptions[name] = valueDict;
+
+ self.options = [NSDictionary dictionaryWithDictionary:mutableOptions];
+ }];
+}
+
@end
diff --git a/WordPress/Classes/Models/BlogAuthor.swift b/WordPress/Classes/Models/BlogAuthor.swift
index 22cd22340607..66d00b8661cb 100644
--- a/WordPress/Classes/Models/BlogAuthor.swift
+++ b/WordPress/Classes/Models/BlogAuthor.swift
@@ -11,4 +11,5 @@ public class BlogAuthor: NSManagedObject {
@NSManaged public var avatarURL: String?
@NSManaged public var linkedUserID: NSNumber?
@NSManaged public var blog: Blog?
+ @NSManaged public var deletedFromBlog: Bool
}
diff --git a/WordPress/Classes/Models/BlogSettings+Discussion.swift b/WordPress/Classes/Models/BlogSettings+Discussion.swift
index b5f4962ffcce..d3f4ea3151ef 100644
--- a/WordPress/Classes/Models/BlogSettings+Discussion.swift
+++ b/WordPress/Classes/Models/BlogSettings+Discussion.swift
@@ -194,7 +194,7 @@ extension BlogSettings {
get {
if commentsRequireManualModeration {
return .disabled
- } else if commentsFromKnownUsersWhitelisted {
+ } else if commentsFromKnownUsersAllowlisted {
return .fromKnownUsers
}
@@ -202,7 +202,7 @@ extension BlogSettings {
}
set {
commentsRequireManualModeration = newValue == .disabled
- commentsFromKnownUsersWhitelisted = newValue == .fromKnownUsers
+ commentsFromKnownUsersAllowlisted = newValue == .fromKnownUsers
}
}
diff --git a/WordPress/Classes/Models/BlogSettings.swift b/WordPress/Classes/Models/BlogSettings.swift
index f320d1243c8b..c6c548b8e222 100644
--- a/WordPress/Classes/Models/BlogSettings.swift
+++ b/WordPress/Classes/Models/BlogSettings.swift
@@ -81,9 +81,9 @@ open class BlogSettings: NSManagedObject {
///
@NSManaged var commentsAllowed: Bool
- /// Contains a list of words, space separated, that would cause a comment to be automatically blacklisted.
+ /// Contains a list of words, space separated, that would cause a comment to be automatically blocklisted.
///
- @NSManaged var commentsBlacklistKeys: Set?
+ @NSManaged var commentsBlocklistKeys: Set?
/// If true, comments will be automatically closed after the number of days, specified by `commentsCloseAutomaticallyAfterDays`.
///
@@ -94,9 +94,9 @@ open class BlogSettings: NSManagedObject {
///
@NSManaged var commentsCloseAutomaticallyAfterDays: NSNumber?
- /// When enabled, comments from known users will be whitelisted.
+ /// When enabled, comments from known users will be allowlisted.
///
- @NSManaged var commentsFromKnownUsersWhitelisted: Bool
+ @NSManaged var commentsFromKnownUsersAllowlisted: Bool
/// Indicates the maximum number of links allowed per comment. When a new comment exceeds this number,
/// it'll be held in queue for moderation.
@@ -231,7 +231,7 @@ open class BlogSettings: NSManagedObject {
/// List of IP addresses that will never be blocked for logins by Jetpack
///
- @NSManaged var jetpackLoginWhiteListedIPAddresses: Set?
+ @NSManaged var jetpackLoginAllowListedIPAddresses: Set?
/// Indicates whether WordPress.com SSO is enabled for the Jetpack site
///
diff --git a/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift b/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift
new file mode 100644
index 000000000000..7b9e0ccd615c
--- /dev/null
+++ b/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift
@@ -0,0 +1,91 @@
+import Foundation
+import CoreData
+import WordPressKit
+
+public class BloggingPrompt: NSManagedObject {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: Self.classNameWithoutNamespaces())
+ }
+
+ @nonobjc public class func newObject(in context: NSManagedObjectContext) -> BloggingPrompt? {
+ return NSEntityDescription.insertNewObject(forEntityName: Self.classNameWithoutNamespaces(), into: context) as? BloggingPrompt
+ }
+
+ public override func awakeFromInsert() {
+ self.date = .init(timeIntervalSince1970: 0)
+ self.displayAvatarURLs = []
+ }
+
+ var promptAttribution: BloggingPromptsAttribution? {
+ BloggingPromptsAttribution(rawValue: attribution.lowercased())
+ }
+
+ /// Convenience method to map properties from `RemoteBloggingPrompt`.
+ ///
+ /// - Parameters:
+ /// - remotePrompt: The remote prompt model to convert
+ /// - siteID: The ID of the site that the prompt is intended for
+ func configure(with remotePrompt: RemoteBloggingPrompt, for siteID: Int32) {
+ self.promptID = Int32(remotePrompt.promptID)
+ self.siteID = siteID
+ self.text = remotePrompt.text
+ self.title = remotePrompt.title
+ self.content = remotePrompt.content
+ self.attribution = remotePrompt.attribution
+ self.date = remotePrompt.date
+ self.answered = remotePrompt.answered
+ self.answerCount = Int32(remotePrompt.answeredUsersCount)
+ self.displayAvatarURLs = remotePrompt.answeredUserAvatarURLs
+ }
+
+ func textForDisplay() -> String {
+ return text.stringByDecodingXMLCharacters().trim()
+ }
+
+ /// Convenience method that checks if the given date is within the same day of the prompt's date without considering the timezone information.
+ ///
+ /// Example: `2022-05-19 23:00:00 UTC-5` and `2022-05-20 00:00:00 UTC` are both dates within the same day (when the UTC date is converted to UTC-5),
+ /// but this method will return `false`.
+ ///
+ /// - Parameters:
+ /// - localDate: The date to compare against in local timezone.
+ /// - Returns: True if the year, month, and day components of the `localDate` matches the prompt's localized date.
+ func inSameDay(as dateToCompare: Date) -> Bool {
+ return DateFormatters.utc.string(from: date) == DateFormatters.local.string(from: dateToCompare)
+ }
+}
+
+// MARK: - Notification Payload
+
+extension BloggingPrompt {
+
+ struct NotificationKeys {
+ static let promptID = "prompt_id"
+ static let siteID = "site_id"
+ }
+
+}
+
+// MARK: - Private Helpers
+
+private extension BloggingPrompt {
+
+ struct DateFormatters {
+ static let local: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = .init(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter
+ }()
+
+ static let utc: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = .init(identifier: "en_US_POSIX")
+ formatter.timeZone = .init(secondsFromGMT: 0)
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter
+ }()
+ }
+
+}
diff --git a/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift b/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift
new file mode 100644
index 000000000000..a40e60d39968
--- /dev/null
+++ b/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift
@@ -0,0 +1,34 @@
+import Foundation
+import CoreData
+
+extension BloggingPrompt {
+ /// The unique ID for the prompt, received from the server.
+ @NSManaged public var promptID: Int32
+
+ /// The site ID for the prompt.
+ @NSManaged public var siteID: Int32
+
+ /// The prompt content to be displayed at entry points.
+ @NSManaged public var text: String
+
+ /// Template title for the draft post.
+ @NSManaged public var title: String
+
+ /// Template content for the draft post.
+ @NSManaged public var content: String
+
+ /// The attribution source for the prompt.
+ @NSManaged public var attribution: String
+
+ /// The prompt date. Time information should be ignored.
+ @NSManaged public var date: Date
+
+ /// Whether the current user has answered the prompt in `siteID`.
+ @NSManaged public var answered: Bool
+
+ /// The number of users that has answered the prompt.
+ @NSManaged public var answerCount: Int32
+
+ /// Contains avatar URLs of some users that have answered the prompt.
+ @NSManaged public var displayAvatarURLs: [URL]
+}
diff --git a/WordPress/Classes/Models/BloggingPromptSettings+CoreDataClass.swift b/WordPress/Classes/Models/BloggingPromptSettings+CoreDataClass.swift
new file mode 100644
index 000000000000..a92a16fde33e
--- /dev/null
+++ b/WordPress/Classes/Models/BloggingPromptSettings+CoreDataClass.swift
@@ -0,0 +1,52 @@
+import Foundation
+import CoreData
+import WordPressKit
+
+public class BloggingPromptSettings: NSManagedObject {
+
+ func configure(with remoteSettings: RemoteBloggingPromptsSettings, siteID: Int32, context: NSManagedObjectContext) {
+ self.siteID = siteID
+ self.promptCardEnabled = remoteSettings.promptCardEnabled
+ self.reminderTime = remoteSettings.reminderTime
+ self.promptRemindersEnabled = remoteSettings.promptRemindersEnabled
+ self.isPotentialBloggingSite = remoteSettings.isPotentialBloggingSite
+ updatePromptSettingsIfNecessary(siteID: Int(siteID), enabled: isPotentialBloggingSite)
+ self.reminderDays = reminderDays ?? BloggingPromptSettingsReminderDays(context: context)
+ reminderDays?.configure(with: remoteSettings.reminderDays)
+ }
+
+ func reminderTimeDate() -> Date? {
+ guard let reminderTime = reminderTime else {
+ return nil
+ }
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "HH.mm"
+ return dateFormatter.date(from: reminderTime)
+ }
+
+ private func updatePromptSettingsIfNecessary(siteID: Int, enabled: Bool) {
+ let service = BlogDashboardPersonalizationService(siteID: siteID)
+ if !service.hasPreference(for: .prompts) {
+ service.setEnabled(enabled, for: .prompts)
+ }
+ }
+
+}
+
+extension RemoteBloggingPromptsSettings {
+
+ init(with model: BloggingPromptSettings) {
+ self.init(promptCardEnabled: model.promptCardEnabled,
+ promptRemindersEnabled: model.promptRemindersEnabled,
+ reminderDays: ReminderDays(monday: model.reminderDays?.monday ?? false,
+ tuesday: model.reminderDays?.tuesday ?? false,
+ wednesday: model.reminderDays?.wednesday ?? false,
+ thursday: model.reminderDays?.thursday ?? false,
+ friday: model.reminderDays?.friday ?? false,
+ saturday: model.reminderDays?.saturday ?? false,
+ sunday: model.reminderDays?.sunday ?? false),
+ reminderTime: model.reminderTime ?? String(),
+ isPotentialBloggingSite: model.isPotentialBloggingSite)
+ }
+
+}
diff --git a/WordPress/Classes/Models/BloggingPromptSettings+CoreDataProperties.swift b/WordPress/Classes/Models/BloggingPromptSettings+CoreDataProperties.swift
new file mode 100644
index 000000000000..12ff178acd6d
--- /dev/null
+++ b/WordPress/Classes/Models/BloggingPromptSettings+CoreDataProperties.swift
@@ -0,0 +1,17 @@
+import Foundation
+import CoreData
+
+extension BloggingPromptSettings {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "BloggingPromptSettings")
+ }
+
+ @NSManaged public var isPotentialBloggingSite: Bool
+ @NSManaged public var promptCardEnabled: Bool
+ @NSManaged public var promptRemindersEnabled: Bool
+ @NSManaged public var reminderTime: String?
+ @NSManaged public var siteID: Int32
+ @NSManaged public var reminderDays: BloggingPromptSettingsReminderDays?
+
+}
diff --git a/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataClass.swift b/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataClass.swift
new file mode 100644
index 000000000000..bb883f1798f0
--- /dev/null
+++ b/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataClass.swift
@@ -0,0 +1,34 @@
+import Foundation
+import CoreData
+import WordPressKit
+
+public class BloggingPromptSettingsReminderDays: NSManagedObject {
+
+ func configure(with remoteReminderDays: RemoteBloggingPromptsSettings.ReminderDays) {
+ self.monday = remoteReminderDays.monday
+ self.tuesday = remoteReminderDays.tuesday
+ self.wednesday = remoteReminderDays.wednesday
+ self.thursday = remoteReminderDays.thursday
+ self.friday = remoteReminderDays.friday
+ self.saturday = remoteReminderDays.saturday
+ self.sunday = remoteReminderDays.sunday
+ }
+
+ func getActiveWeekdays() -> [BloggingRemindersScheduler.Weekday] {
+ return [
+ sunday,
+ monday,
+ tuesday,
+ wednesday,
+ thursday,
+ friday,
+ saturday
+ ].enumerated().compactMap { (index: Int, isReminderActive: Bool) in
+ guard isReminderActive else {
+ return nil
+ }
+ return BloggingRemindersScheduler.Weekday(rawValue: index)
+ }
+ }
+
+}
diff --git a/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataProperties.swift b/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataProperties.swift
new file mode 100644
index 000000000000..f2ef0e74dd87
--- /dev/null
+++ b/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataProperties.swift
@@ -0,0 +1,19 @@
+import Foundation
+import CoreData
+
+extension BloggingPromptSettingsReminderDays {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "BloggingPromptSettingsReminderDays")
+ }
+
+ @NSManaged public var monday: Bool
+ @NSManaged public var tuesday: Bool
+ @NSManaged public var wednesday: Bool
+ @NSManaged public var thursday: Bool
+ @NSManaged public var friday: Bool
+ @NSManaged public var saturday: Bool
+ @NSManaged public var sunday: Bool
+ @NSManaged public var settings: BloggingPromptSettings?
+
+}
diff --git a/WordPress/Classes/Models/Comment+CoreDataClass.swift b/WordPress/Classes/Models/Comment+CoreDataClass.swift
new file mode 100644
index 000000000000..6af39dba371a
--- /dev/null
+++ b/WordPress/Classes/Models/Comment+CoreDataClass.swift
@@ -0,0 +1,237 @@
+import Foundation
+import CoreData
+
+@objc(Comment)
+public class Comment: NSManagedObject {
+
+ @objc static func descriptionFor(_ commentStatus: CommentStatusType) -> String {
+ return commentStatus.description
+ }
+
+ @objc func authorUrlForDisplay() -> String {
+ return authorURL()?.host ?? String()
+ }
+
+ @objc func contentForEdit() -> String {
+ return availableContent()
+ }
+
+ @objc func isApproved() -> Bool {
+ return status.isEqual(to: CommentStatusType.approved.description)
+ }
+
+ @objc func isReadOnly() -> Bool {
+ guard let blog = blog else {
+ return true
+ }
+
+ // If the current user cannot moderate the comment, they can only Like and Reply if the comment is Approved.
+ return (blog.isHostedAtWPcom || blog.isAtomic()) && !canModerate && !isApproved()
+ }
+
+ // This can be removed when `unifiedCommentsAndNotificationsList` is permanently enabled
+ // as it's replaced by Comment+Interface:relativeDateSectionIdentifier.
+ @objc func sectionIdentifier() -> String? {
+ guard let dateCreated = dateCreated else {
+ return nil
+ }
+
+ let formatter = DateFormatter()
+ formatter.dateStyle = .long
+ formatter.timeStyle = .none
+ return formatter.string(from: dateCreated)
+ }
+
+ @objc func commentURL() -> URL? {
+ guard !link.isEmpty else {
+ return nil
+ }
+
+ return URL(string: link)
+ }
+
+ @objc func deleteWillBePermanent() -> Bool {
+ return status.isEqual(to: Comment.descriptionFor(.spam)) || status.isEqual(to: Comment.descriptionFor(.unapproved))
+ }
+
+ func numberOfLikes() -> Int {
+ return Int(likeCount)
+ }
+
+ func hasAuthorUrl() -> Bool {
+ return !author_url.isEmpty
+ }
+
+ func canEditAuthorData() -> Bool {
+ // If the authorID is zero, the user is unregistered. Therefore, the data can be edited.
+ return authorID == 0
+ }
+
+ func hasParentComment() -> Bool {
+ return parentID > 0
+ }
+
+
+ /// Convenience method to check if the current user can actually moderate.
+ /// `canModerate` is only applicable when the site is dotcom-related (hosted or atomic). For self-hosted sites, default to true.
+ @objc func allowsModeration() -> Bool {
+ if let _ = post as? ReaderPost {
+ return canModerate
+ }
+
+ guard let blog = blog,
+ (blog.isHostedAtWPcom || blog.isAtomic()) else {
+ return true
+ }
+ return canModerate
+ }
+
+ func canReply() -> Bool {
+ if let readerPost = post as? ReaderPost {
+ return readerPost.commentsOpen && ReaderHelpers.isLoggedIn()
+ }
+
+ return !isReadOnly()
+ }
+
+ // NOTE: Comment Likes could be disabled, but the API doesn't have that info yet. Let's update this once it's available.
+ func canLike() -> Bool {
+ if let _ = post as? ReaderPost {
+ return ReaderHelpers.isLoggedIn()
+ }
+
+ guard let blog = blog else {
+ // Disable likes feature for self-hosted sites.
+ return false
+ }
+
+ return !isReadOnly() && blog.supports(.commentLikes)
+ }
+
+ @objc func isTopLevelComment() -> Bool {
+ return depth == 0
+ }
+
+ func isFromPostAuthor() -> Bool {
+ guard let postAuthorID = post?.authorID?.int32Value,
+ postAuthorID > 0,
+ authorID > 0 else {
+ return false
+ }
+
+ return authorID == postAuthorID
+ }
+}
+
+private extension Comment {
+
+ func decodedContent() -> String {
+ // rawContent/content contains markup for Gutenberg comments. Remove it so it's not displayed.
+ return availableContent().stringByDecodingXMLCharacters().trim().strippingHTML().normalizingWhitespace() ?? String()
+ }
+
+ func authorName() -> String {
+ return !author.isEmpty ? author : NSLocalizedString("Anonymous", comment: "the comment has an anonymous author.")
+ }
+
+ // The REST endpoint response contains both content and rawContent.
+ // The XMLRPC endpoint response contains only content.
+ // So for Comment display and Comment editing, use which content the Comment has.
+ // The result is WP sites will use rawContent, self-hosted will use content.
+ func availableContent() -> String {
+ if !rawContent.isEmpty {
+ return rawContent
+ }
+
+ if !content.isEmpty {
+ return content
+ }
+
+ return String()
+ }
+
+}
+
+extension Comment: PostContentProvider {
+
+ public func titleForDisplay() -> String {
+ let title = post?.postTitle ?? postTitle
+ return !title.isEmpty ? title.stringByDecodingXMLCharacters() : NSLocalizedString("(No Title)", comment: "Empty Post Title")
+ }
+
+ @objc public func authorForDisplay() -> String {
+ let displayAuthor = authorName().stringByDecodingXMLCharacters().trim()
+ return !displayAuthor.isEmpty ? displayAuthor : gravatarEmailForDisplay()
+ }
+
+ // Used in Comment details (non-threaded)
+ public func contentForDisplay() -> String {
+ return decodedContent()
+ }
+
+ // Used in Comments list (non-threaded)
+ public func contentPreviewForDisplay() -> String {
+ return decodedContent()
+ }
+
+ public func avatarURLForDisplay() -> URL? {
+ return !authorAvatarURL.isEmpty ? URL(string: authorAvatarURL) : nil
+ }
+
+ public func gravatarEmailForDisplay() -> String {
+ let displayEmail = author_email.trim()
+ return !displayEmail.isEmpty ? displayEmail : String()
+ }
+
+ public func dateForDisplay() -> Date? {
+ return dateCreated
+ }
+
+ @objc public func authorURL() -> URL? {
+ return !author_url.isEmpty ? URL(string: author_url) : nil
+ }
+
+}
+
+// When CommentViewController and CommentService are converted to Swift, this can be simplified to a String enum.
+@objc enum CommentStatusType: Int {
+ case pending
+ case approved
+ case unapproved
+ case spam
+ // Draft status is for comments that have not yet been successfully published/uploaded.
+ // We can use this status to restore comment replies that the user has written.
+ case draft
+
+ var description: String {
+ switch self {
+ case .pending:
+ return "hold"
+ case .approved:
+ return "approve"
+ case .unapproved:
+ return "trash"
+ case .spam:
+ return "spam"
+ case .draft:
+ return "draft"
+ }
+ }
+
+ static func typeForStatus(_ status: String?) -> CommentStatusType? {
+ switch status {
+ case "hold":
+ return .pending
+ case "approve":
+ return .approved
+ case "trash":
+ return .unapproved
+ case "spam":
+ return .spam
+ case "draft":
+ return .draft
+ default:
+ return nil
+ }
+ }
+}
diff --git a/WordPress/Classes/Models/Comment+CoreDataProperties.swift b/WordPress/Classes/Models/Comment+CoreDataProperties.swift
new file mode 100644
index 000000000000..b05378ebc971
--- /dev/null
+++ b/WordPress/Classes/Models/Comment+CoreDataProperties.swift
@@ -0,0 +1,45 @@
+extension Comment {
+ @NSManaged public var commentID: Int32
+ @NSManaged public var postID: Int32
+ @NSManaged public var likeCount: Int16
+ @NSManaged public var dateCreated: Date?
+ @NSManaged public var isLiked: Bool
+ @NSManaged public var canModerate: Bool
+ @NSManaged public var content: String
+ @NSManaged public var rawContent: String
+ @NSManaged public var postTitle: String
+ @NSManaged public var link: String
+ @NSManaged public var status: String
+ @NSManaged public var type: String
+ @NSManaged public var authorID: Int32
+ @NSManaged public var author: String
+ @NSManaged public var author_email: String
+ @NSManaged public var author_url: String
+ @NSManaged public var authorAvatarURL: String
+ @NSManaged public var author_ip: String
+
+ // Relationships
+ @NSManaged public var blog: Blog?
+ @NSManaged public var post: BasePost?
+
+ // Hierarchical properties
+ @NSManaged public var parentID: Int32
+ @NSManaged public var depth: Int16
+ @NSManaged public var hierarchy: String
+ @NSManaged public var replyID: Int32
+
+ /// Determines if the comment should be displayed in the Reader comment thread.
+ ///
+ /// Note that this property is only updated and guaranteed to be correct within the comment thread.
+ /// The value may be outdated when accessed from other places (e.g. My Sites, Notifications).
+ @NSManaged public var visibleOnReader: Bool
+
+ /*
+ // Hierarchy is a string representation of a comments ancestors. Each ancestor's
+ // is denoted by a ten character zero padded representation of its ID
+ // (e.g. "0000000001"). Ancestors are separated by a period.
+ // This allows hierarchical comments to be retrieved from core data by sorting
+ // on hierarchy, and allows for new comments to be inserted without needing to
+ // reorder the list.
+ */
+}
diff --git a/WordPress/Classes/Models/Comment.h b/WordPress/Classes/Models/Comment.h
deleted file mode 100644
index d43c336d6186..000000000000
--- a/WordPress/Classes/Models/Comment.h
+++ /dev/null
@@ -1,57 +0,0 @@
-#import
-#import
-#import "WPCommentContentViewProvider.h"
-
-@class Blog;
-@class BasePost;
-
-// This is the notification name used with NSNotificationCenter
-extern NSString * const CommentUploadFailedNotification;
-
-extern NSString * const CommentStatusPending;
-extern NSString * const CommentStatusApproved;
-extern NSString * const CommentStatusUnapproved;
-extern NSString * const CommentStatusSpam;
-// Draft status is for comments that have not yet been successfully published
-// we can use this status to restore comment replies that the user has written
-extern NSString * const CommentStatusDraft;
-
-@interface Comment : NSManagedObject
-
-@property (nonatomic, strong) Blog *blog;
-@property (nonatomic, strong) BasePost *post;
-@property (nonatomic, strong) NSString *author;
-@property (nonatomic, strong) NSString *author_email;
-@property (nonatomic, strong) NSString *author_ip;
-@property (nonatomic, strong) NSString *author_url;
-@property (nonatomic, strong) NSString *authorAvatarURL;
-@property (nonatomic, strong) NSNumber *commentID;
-@property (nonatomic, strong) NSString *content;
-@property (nonatomic, strong) NSDate *dateCreated;
-@property (nonatomic, strong) NSNumber *depth;
-// Hierarchy is a string representation of a comments ancestors. Each ancestor's
-// is denoted by a ten character zero padded representation of its ID
-// (e.g. "0000000001"). Ancestors are separated by a period.
-// This allows hierarchical comments to be retrieved from core data by sorting
-// on hierarchy, and allows for new comments to be inserted without needing to
-// reorder the list.
-@property (nonatomic, strong) NSString *hierarchy;
-@property (nonatomic, strong) NSString *link;
-@property (nonatomic, strong) NSNumber *parentID;
-@property (nonatomic, strong) NSNumber *postID;
-@property (nonatomic, strong) NSString *postTitle;
-@property (nonatomic, strong) NSString *status;
-@property (nonatomic, strong) NSString *type;
-@property (nonatomic, strong) NSNumber *likeCount;
-@property (nonatomic, strong) NSAttributedString *attributedContent;
-@property (nonatomic) BOOL isLiked;
-@property (nonatomic, assign) BOOL isNew;
-
-/// Helper methods
-///
-+ (NSString *)titleForStatus:(NSString *)status;
-- (NSString *)authorUrlForDisplay;
-- (BOOL)hasAuthorUrl;
-- (BOOL)isApproved;
-
-@end
diff --git a/WordPress/Classes/Models/Comment.m b/WordPress/Classes/Models/Comment.m
deleted file mode 100644
index 446a9d3a0856..000000000000
--- a/WordPress/Classes/Models/Comment.m
+++ /dev/null
@@ -1,200 +0,0 @@
-#import "Comment.h"
-#import "ContextManager.h"
-#import "Blog.h"
-#import "BasePost.h"
-#import
-#import "WordPress-Swift.h"
-
-NSString * const CommentUploadFailedNotification = @"CommentUploadFailed";
-
-NSString * const CommentStatusPending = @"hold";
-NSString * const CommentStatusApproved = @"approve";
-NSString * const CommentStatusUnapproved = @"trash";
-NSString * const CommentStatusSpam = @"spam";
-
-// draft is used for comments that have been composed but not succesfully uploaded yet
-NSString * const CommentStatusDraft = @"draft";
-
-@implementation Comment
-
-@dynamic blog;
-@dynamic post;
-@dynamic author;
-@dynamic author_email;
-@dynamic author_ip;
-@dynamic author_url;
-@dynamic authorAvatarURL;
-@dynamic commentID;
-@dynamic content;
-@dynamic dateCreated;
-@dynamic depth;
-@dynamic hierarchy;
-@dynamic link;
-@dynamic parentID;
-@dynamic postID;
-@dynamic postTitle;
-@dynamic status;
-@dynamic type;
-@dynamic isLiked;
-@dynamic likeCount;
-@synthesize isNew;
-@synthesize attributedContent;
-
-#pragma mark - Helper methods
-
-+ (NSString *)titleForStatus:(NSString *)status
-{
- if ([status isEqualToString:CommentStatusPending]) {
- return NSLocalizedString(@"Pending moderation", @"");
- } else if ([status isEqualToString:CommentStatusApproved]) {
- return NSLocalizedString(@"Comments", @"");
- }
-
- return status;
-}
-
-- (NSString *)postTitle
-{
- NSString *title = nil;
- if (self.post) {
- title = self.post.postTitle;
- } else {
- [self willAccessValueForKey:@"postTitle"];
- title = [self primitiveValueForKey:@"postTitle"];
- [self didAccessValueForKey:@"postTitle"];
- }
-
- if (title == nil || [@"" isEqualToString:title]) {
- title = NSLocalizedString(@"(no title)", @"the post has no title.");
- }
- return title;
-
-}
-
-- (NSString *)author
-{
- NSString *authorName = nil;
-
- [self willAccessValueForKey:@"author"];
- authorName = [self primitiveValueForKey:@"author"];
- [self didAccessValueForKey:@"author"];
-
- if (authorName == nil || [@"" isEqualToString:authorName]) {
- authorName = NSLocalizedString(@"Anonymous", @"the comment has an anonymous author.");
- }
- return authorName;
-
-}
-
-- (NSDate *)dateCreated
-{
- NSDate *date = nil;
-
- [self willAccessValueForKey:@"dateCreated"];
- date = [self primitiveValueForKey:@"dateCreated"];
- [self didAccessValueForKey:@"dateCreated"];
-
- return date;
-}
-
-
-#pragma mark - PostContentProvider protocol
-
-- (BOOL)isPrivateContent
-{
- if ([self.post respondsToSelector:@selector(isPrivate)]) {
- return (BOOL)[self.post performSelector:@selector(isPrivate)];
- }
- return NO;
-}
-
-- (NSString *)titleForDisplay
-{
- return [self.postTitle stringByDecodingXMLCharacters];
-}
-
-- (NSString *)authorForDisplay
-{
- return [[self.author trim] length] > 0 ? [[self.author stringByDecodingXMLCharacters] trim] : [self.author_email trim];
-}
-
-- (NSString *)blogNameForDisplay
-{
- return self.author_url;
-}
-
-- (NSString *)statusForDisplay
-{
- NSString *status = [[self class] titleForStatus:self.status];
- if ([status isEqualToString:NSLocalizedString(@"Comments", @"")]) {
- status = nil;
- }
- return status;
-}
-
-- (NSString *)authorUrlForDisplay
-{
- return self.author_url.hostname;
-}
-
-- (BOOL)hasAuthorUrl
-{
- return self.author_url && ![self.author_url isEqualToString:@""];
-}
-
-- (BOOL)isApproved
-{
- return [self.status isEqualToString:CommentStatusApproved];
-}
-
-- (NSString *)contentForDisplay
-{
- //Strip HTML from the comment content
- NSString *commentContent = [self.content stringByDecodingXMLCharacters];
- commentContent = [commentContent trim];
- commentContent = [commentContent stringByStrippingHTML];
- commentContent = [commentContent stringByNormalizingWhitespace];
-
- return commentContent;
-}
-
-- (NSString *)contentPreviewForDisplay
-{
- return [[[self.content stringByDecodingXMLCharacters] stringByStrippingHTML] stringByNormalizingWhitespace];
-}
-
-- (NSURL *)avatarURLForDisplay
-{
- return [NSURL URLWithString:self.authorAvatarURL];
-}
-
-- (NSString *)gravatarEmailForDisplay
-{
- return [self.author_email trim];
-}
-
-- (NSDate *)dateForDisplay
-{
- return self.dateCreated;
-}
-
-- (NSURL *)authorURL
-{
- if (self.author_url) {
- return [NSURL URLWithString:self.author_url];
- }
-
- return nil;
-}
-
-- (BOOL)authorIsPostAuthor
-{
- return [[self authorURL] isEqual:[self.post authorURL]];
-}
-
-- (NSNumber *)numberOfLikes
-{
- return self.likeCount ?: @(0);
-}
-
-@end
diff --git a/WordPress/Classes/Models/Coordinate.h b/WordPress/Classes/Models/Coordinate.h
index c92c30adb95d..ddf2a2a829f5 100644
--- a/WordPress/Classes/Models/Coordinate.h
+++ b/WordPress/Classes/Models/Coordinate.h
@@ -1,7 +1,7 @@
#import
#import
-@interface Coordinate : NSObject {
+@interface Coordinate : NSObject {
CLLocationCoordinate2D _coordinate;
}
diff --git a/WordPress/Classes/Models/Coordinate.m b/WordPress/Classes/Models/Coordinate.m
index 9fe6ba583fef..fa42e4086993 100644
--- a/WordPress/Classes/Models/Coordinate.m
+++ b/WordPress/Classes/Models/Coordinate.m
@@ -22,19 +22,24 @@ - (CLLocationDegrees)longitude
}
#pragma mark -
-#pragma mark NSCoding
+#pragma mark NSSecureCoding
+
++ (BOOL)supportsSecureCoding
+{
+ return YES;
+}
- (void)encodeWithCoder:(NSCoder *)encoder
{
- [encoder encodeDouble:_coordinate.latitude forKey:@"latitude"];
- [encoder encodeDouble:_coordinate.longitude forKey:@"longitude"];
+ [encoder encodeObject:@(_coordinate.latitude) forKey:@"latitude"];
+ [encoder encodeObject:@(_coordinate.longitude) forKey:@"longitude"];
}
- (id)initWithCoder:(NSCoder *)decoder
{
if (self = [super init]) {
- _coordinate.latitude = [decoder decodeDoubleForKey:@"latitude"];
- _coordinate.longitude = [decoder decodeDoubleForKey:@"longitude"];
+ _coordinate.latitude = [[decoder decodeObjectOfClass:[NSNumber class] forKey:@"latitude"] doubleValue];
+ _coordinate.longitude = [[decoder decodeObjectOfClass:[NSNumber class] forKey:@"longitude"] doubleValue];
}
return self;
diff --git a/WordPress/Classes/Models/Domain.swift b/WordPress/Classes/Models/Domain.swift
index cad8302a20fb..815ee3eede53 100644
--- a/WordPress/Classes/Models/Domain.swift
+++ b/WordPress/Classes/Models/Domain.swift
@@ -8,7 +8,12 @@ extension Domain {
init(managedDomain: ManagedDomain) {
self.init(domainName: managedDomain.domainName,
isPrimaryDomain: managedDomain.isPrimary,
- domainType: managedDomain.domainType)
+ domainType: managedDomain.domainType,
+ autoRenewing: managedDomain.autoRenewing,
+ autoRenewalDate: managedDomain.autoRenewalDate,
+ expirySoon: managedDomain.expirySoon,
+ expired: managedDomain.expired,
+ expiryDate: managedDomain.expiryDate)
}
}
@@ -24,6 +29,11 @@ class ManagedDomain: NSManagedObject {
static let domainName = "domainName"
static let isPrimary = "isPrimary"
static let domainType = "domainType"
+ static let autoRenewing = "autoRenewing"
+ static let autoRenewalDate = "autoRenewalDate"
+ static let expirySoon = "expirySoon"
+ static let expired = "expired"
+ static let expiryDate = "expiryDate"
}
struct Relationships {
@@ -34,12 +44,23 @@ class ManagedDomain: NSManagedObject {
@NSManaged var isPrimary: Bool
@NSManaged var domainType: DomainType
@NSManaged var blog: Blog
+ @NSManaged var autoRenewing: Bool
+ @NSManaged var autoRenewalDate: String
+ @NSManaged var expirySoon: Bool
+ @NSManaged var expired: Bool
+ @NSManaged var expiryDate: String
func updateWith(_ domain: Domain, blog: Blog) {
self.domainName = domain.domainName
self.isPrimary = domain.isPrimaryDomain
self.domainType = domain.domainType
self.blog = blog
+
+ self.autoRenewing = domain.autoRenewing
+ self.autoRenewalDate = domain.autoRenewalDate
+ self.expirySoon = domain.expirySoon
+ self.expired = domain.expired
+ self.expiryDate = domain.expiryDate
}
}
@@ -48,5 +69,10 @@ extension Domain: Equatable {}
public func ==(lhs: Domain, rhs: Domain) -> Bool {
return lhs.domainName == rhs.domainName &&
lhs.domainType == rhs.domainType &&
- lhs.isPrimaryDomain == rhs.isPrimaryDomain
+ lhs.isPrimaryDomain == rhs.isPrimaryDomain &&
+ lhs.autoRenewing == rhs.autoRenewing &&
+ lhs.autoRenewalDate == rhs.autoRenewalDate &&
+ lhs.expirySoon == rhs.expirySoon &&
+ lhs.expired == rhs.expired &&
+ lhs.expiryDate == rhs.expiryDate
}
diff --git a/WordPress/Classes/Models/InviteLinks+CoreDataClass.swift b/WordPress/Classes/Models/InviteLinks+CoreDataClass.swift
new file mode 100644
index 000000000000..8b5e52199615
--- /dev/null
+++ b/WordPress/Classes/Models/InviteLinks+CoreDataClass.swift
@@ -0,0 +1,7 @@
+import Foundation
+import CoreData
+
+@objc(InviteLinks)
+public class InviteLinks: NSManagedObject {
+
+}
diff --git a/WordPress/Classes/Models/InviteLinks+CoreDataProperties.swift b/WordPress/Classes/Models/InviteLinks+CoreDataProperties.swift
new file mode 100644
index 000000000000..5868f4feab82
--- /dev/null
+++ b/WordPress/Classes/Models/InviteLinks+CoreDataProperties.swift
@@ -0,0 +1,20 @@
+import Foundation
+import CoreData
+
+
+extension InviteLinks {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "InviteLinks")
+ }
+
+ @NSManaged public var inviteKey: String!
+ @NSManaged public var role: String!
+ @NSManaged public var isPending: Bool
+ @NSManaged public var inviteDate: Date!
+ @NSManaged public var groupInvite: Bool
+ @NSManaged public var expiry: Int64
+ @NSManaged public var link: String!
+ @NSManaged public var blog: Blog!
+
+}
diff --git a/WordPress/Classes/Models/JetpackSiteRef.swift b/WordPress/Classes/Models/JetpackSiteRef.swift
index 3570ac2187a4..a33c101f22ce 100644
--- a/WordPress/Classes/Models/JetpackSiteRef.swift
+++ b/WordPress/Classes/Models/JetpackSiteRef.swift
@@ -13,14 +13,57 @@ struct JetpackSiteRef: Hashable, Codable {
let siteID: Int
/// The WordPress.com username.
let username: String
+ /// The homeURL string for a site.
+ let homeURL: String
+
+ private var hasBackup = false
+ private var hasPaidPlan = false
+
+ // Self Hosted Non Jetpack Support
+ // Ideally this would be a different "ref" object but the JetpackSiteRef
+ // is so coupled into the plugin management that the amount of changes and work needed to change
+ // would be very large. This is a workaround for that.
+ let isSelfHostedWithoutJetpack: Bool
+
+ /// The XMLRPC path for the site, only applies to self hosted sites with no Jetpack connected
+ var xmlRPC: String? = nil
init?(blog: Blog) {
- guard let username = blog.account?.username,
- let siteID = blog.dotComID as? Int else {
+
+ // Init for self hosted and no Jetpack
+ if blog.account == nil, !blog.isHostedAtWPcom {
+ guard
+ let username = blog.username,
+ let homeURL = blog.homeURL as String?,
+ let xmlRPC = blog.xmlrpc
+ else {
return nil
+ }
+
+ self.isSelfHostedWithoutJetpack = true
+ self.username = username
+ self.siteID = Constants.selfHostedSiteID
+ self.homeURL = homeURL
+ self.xmlRPC = xmlRPC
+ }
+
+ // Init for normal Jetpack connected sites
+ else {
+ guard
+ let username = blog.account?.username,
+ let siteID = blog.dotComID as? Int,
+ let homeURL = blog.homeURL as String?
+ else {
+ return nil
+ }
+
+ self.isSelfHostedWithoutJetpack = false
+ self.siteID = siteID
+ self.username = username
+ self.homeURL = homeURL
+ self.hasBackup = blog.isBackupsAllowed()
+ self.hasPaidPlan = blog.hasPaidPlan
}
- self.siteID = siteID
- self.username = username
}
public func hash(into hasher: inout Hasher) {
@@ -30,5 +73,16 @@ struct JetpackSiteRef: Hashable, Codable {
static func ==(lhs: JetpackSiteRef, rhs: JetpackSiteRef) -> Bool {
return lhs.siteID == rhs.siteID
&& lhs.username == rhs.username
+ && lhs.homeURL == rhs.homeURL
+ && lhs.hasBackup == rhs.hasBackup
+ && lhs.hasPaidPlan == rhs.hasPaidPlan
+ }
+
+ func shouldShowActivityLogFilter() -> Bool {
+ hasBackup || hasPaidPlan
+ }
+
+ struct Constants {
+ static let selfHostedSiteID = -1
}
}
diff --git a/WordPress/Classes/Models/JetpackState.swift b/WordPress/Classes/Models/JetpackState.swift
index bd4d377a0074..96b0c1fe0ea6 100644
--- a/WordPress/Classes/Models/JetpackState.swift
+++ b/WordPress/Classes/Models/JetpackState.swift
@@ -25,6 +25,13 @@
return true
}
+ /// Return true is Jetpack has site-connection (Jetpack plugin connected to the site but not connected to WP.com account)
+ var isSiteConnection: Bool {
+ let isUserConnected = connectedUsername != nil || connectedEmail != nil
+
+ return isConnected && !isUserConnected
+ }
+
/// Returns YES if the detected version meets the app requirements.
/// - SeeAlso: JetpackVersionMinimumRequired
diff --git a/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataClass.swift b/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataClass.swift
index 6fda524e757a..841df25b723a 100644
--- a/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataClass.swift
+++ b/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataClass.swift
@@ -10,6 +10,13 @@ public class LastPostStatsRecordValue: StatsRecordValue {
return URL(string: url)
}
+ public var featuredImageURL: URL? {
+ guard let url = featuredImageUrlString as String? else {
+ return nil
+ }
+ return URL(string: url)
+ }
+
public override func validateForInsert() throws {
try super.validateForInsert()
try recordValueSingleValueValidation()
@@ -27,6 +34,7 @@ extension StatsLastPostInsight: StatsRecordValueConvertible {
value.urlString = self.url.absoluteString
value.viewsCount = Int64(self.viewsCount)
value.postID = Int64(self.postID)
+ value.featuredImageUrlString = self.featuredImageURL?.absoluteString
return [value]
}
@@ -47,7 +55,8 @@ extension StatsLastPostInsight: StatsRecordValueConvertible {
likesCount: Int(insight.likesCount),
commentsCount: Int(insight.commentsCount),
viewsCount: Int(insight.viewsCount),
- postID: Int(insight.postID))
+ postID: Int(insight.postID),
+ featuredImageURL: insight.featuredImageURL)
}
static var recordType: StatsRecordType {
diff --git a/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataProperties.swift b/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataProperties.swift
index 4ab5dfddd611..7248acef20f3 100644
--- a/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataProperties.swift
+++ b/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataProperties.swift
@@ -15,5 +15,6 @@ extension LastPostStatsRecordValue {
@NSManaged public var urlString: String?
@NSManaged public var viewsCount: Int64
@NSManaged public var postID: Int64
+ @NSManaged public var featuredImageUrlString: String?
}
diff --git a/WordPress/Classes/Models/ManagedAccountSettings+CoreDataProperties.swift b/WordPress/Classes/Models/ManagedAccountSettings+CoreDataProperties.swift
index e39208568106..228db45f3a5a 100644
--- a/WordPress/Classes/Models/ManagedAccountSettings+CoreDataProperties.swift
+++ b/WordPress/Classes/Models/ManagedAccountSettings+CoreDataProperties.swift
@@ -18,6 +18,8 @@ extension ManagedAccountSettings {
@NSManaged var webAddress: String
@NSManaged var language: String
@NSManaged var tracksOptOut: Bool
+ @NSManaged var blockEmailNotifications: Bool
+ @NSManaged var twoStepEnabled: Bool
@NSManaged var account: WPAccount
}
diff --git a/WordPress/Classes/Models/ManagedAccountSettings.swift b/WordPress/Classes/Models/ManagedAccountSettings.swift
index 4378d125a864..dbfb29fcd84a 100644
--- a/WordPress/Classes/Models/ManagedAccountSettings.swift
+++ b/WordPress/Classes/Models/ManagedAccountSettings.swift
@@ -27,6 +27,8 @@ class ManagedAccountSettings: NSManagedObject {
webAddress = accountSettings.webAddress
language = accountSettings.language
tracksOptOut = accountSettings.tracksOptOut
+ blockEmailNotifications = accountSettings.blockEmailNotifications
+ twoStepEnabled = accountSettings.twoStepEnabled
}
/// Applies a change to the account settings
@@ -107,7 +109,9 @@ extension AccountSettings {
primarySiteID: managed.primarySiteID.intValue,
webAddress: managed.webAddress,
language: managed.language,
- tracksOptOut: managed.tracksOptOut)
+ tracksOptOut: managed.tracksOptOut,
+ blockEmailNotifications: managed.blockEmailNotifications,
+ twoStepEnabled: managed.twoStepEnabled)
}
var emailForDisplay: String {
diff --git a/WordPress/Classes/Models/ManagedPerson.swift b/WordPress/Classes/Models/ManagedPerson.swift
index b176e0756259..498719586365 100644
--- a/WordPress/Classes/Models/ManagedPerson.swift
+++ b/WordPress/Classes/Models/ManagedPerson.swift
@@ -30,6 +30,8 @@ class ManagedPerson: NSManagedObject {
return User(managedPerson: self)
case PersonKind.viewer.rawValue:
return Viewer(managedPerson: self)
+ case PersonKind.emailFollower.rawValue:
+ return EmailFollower(managedPerson: self)
default:
return Follower(managedPerson: self)
}
@@ -97,3 +99,18 @@ extension Viewer {
isSuperAdmin: managedPerson.isSuperAdmin)
}
}
+
+extension EmailFollower {
+ init(managedPerson: ManagedPerson) {
+ self.init(ID: Int(managedPerson.userID),
+ username: managedPerson.username,
+ firstName: managedPerson.firstName,
+ lastName: managedPerson.lastName,
+ displayName: managedPerson.displayName,
+ role: RemoteRole.follower.slug,
+ siteID: Int(managedPerson.siteID),
+ linkedUserID: Int(managedPerson.linkedUserID),
+ avatarURL: managedPerson.avatarURL.flatMap { URL(string: $0) },
+ isSuperAdmin: managedPerson.isSuperAdmin)
+ }
+}
diff --git a/WordPress/Classes/Models/Media.h b/WordPress/Classes/Models/Media.h
index 371f3d8df56f..48bac74a6a61 100644
--- a/WordPress/Classes/Models/Media.h
+++ b/WordPress/Classes/Models/Media.h
@@ -42,6 +42,8 @@ typedef NS_ENUM(NSUInteger, MediaType) {
@property (nonatomic, strong, nullable) NSNumber *remoteStatusNumber;
@property (nonatomic, strong, nullable) NSString *remoteThumbnailURL;
@property (nonatomic, strong, nullable) NSString *remoteURL;
+@property (nonatomic, strong, nullable) NSString *remoteLargeURL;
+@property (nonatomic, strong, nullable) NSString *remoteMediumURL;
@property (nonatomic, strong, nullable) NSString *shortcode;
@property (nonatomic, strong, nullable) NSString *title;
@property (nonatomic, strong, nullable) NSString *videopressGUID;
diff --git a/WordPress/Classes/Models/Media.m b/WordPress/Classes/Models/Media.m
index 5729473fb5c2..c6bd5de908bd 100644
--- a/WordPress/Classes/Models/Media.m
+++ b/WordPress/Classes/Models/Media.m
@@ -1,5 +1,5 @@
#import "Media.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "WordPress-Swift.h"
@implementation Media
@@ -7,6 +7,8 @@ @implementation Media
@dynamic alt;
@dynamic mediaID;
@dynamic remoteURL;
+@dynamic remoteLargeURL;
+@dynamic remoteMediumURL;
@dynamic localURL;
@dynamic shortcode;
@dynamic width;
@@ -261,4 +263,18 @@ - (BOOL)hasRemote {
return self.mediaID.intValue != 0;
}
+- (void)setError:(NSError *)error
+{
+ if (error != nil) {
+ // Cherry pick keys that support secure coding. NSErrors thrown from the OS can
+ // contain types that don't adopt NSSecureCoding, leading to a Core Data exception and crash.
+ NSDictionary *userInfo = @{NSLocalizedDescriptionKey: error.localizedDescription};
+ error = [NSError errorWithDomain:error.domain code:error.code userInfo:userInfo];
+ }
+
+ [self willChangeValueForKey:@"error"];
+ [self setPrimitiveValue:error forKey:@"error"];
+ [self didChangeValueForKey:@"error"];
+}
+
@end
diff --git a/WordPress/Classes/Models/MenuItem.h b/WordPress/Classes/Models/MenuItem.h
index 81edfbb5b098..0c0ab9943859 100644
--- a/WordPress/Classes/Models/MenuItem.h
+++ b/WordPress/Classes/Models/MenuItem.h
@@ -105,13 +105,21 @@ extern NSString * const MenuItemLinkTargetBlank;
@param orderedItems an ordered set of items nested top-down as a parent followed by children. (as returned from the Menus API)
@returns MenuItem the last child of self to occur in the ordered set.
*/
-- (MenuItem *)lastDescendantInOrderedItems:(NSOrderedSet *)orderedItems;
+- (nullable MenuItem *)lastDescendantInOrderedItems:(NSOrderedSet *)orderedItems;
/**
The item's name is nil, empty, or the default string.
*/
- (BOOL)nameIsEmptyOrDefault;
+/**
+ Search for a sibling that precedes self.
+ A sibling is a MenuItem that shares the same parent.
+ @param orderedItems an ordered set of items nested top-down as a parent followed by children. (as returned from the Menus API)
+ @returns MenuItem sibling that precedes self, or nil if there is not one.
+ */
+- (nullable MenuItem *)precedingSiblingInOrderedItems:(NSOrderedSet *)orderedItems;
+
@end
@interface MenuItem (CoreDataGeneratedAccessors)
diff --git a/WordPress/Classes/Models/MenuItem.m b/WordPress/Classes/Models/MenuItem.m
index 9213aa5d141f..d90055a2e6ff 100644
--- a/WordPress/Classes/Models/MenuItem.m
+++ b/WordPress/Classes/Models/MenuItem.m
@@ -94,7 +94,7 @@ - (BOOL)isDescendantOfItem:(MenuItem *)item
/**
Traverse the orderedItems for parent items equal to self or that are a descendant of self (a child of a child).
*/
-- (MenuItem *)lastDescendantInOrderedItems:(NSOrderedSet *)orderedItems
+- (nullable MenuItem *)lastDescendantInOrderedItems:(NSOrderedSet *)orderedItems
{
MenuItem *lastChildItem = nil;
NSUInteger parentIndex = [orderedItems indexOfObject:self];
@@ -115,4 +115,18 @@ - (BOOL)nameIsEmptyOrDefault
return self.name.length == 0 || [self.name isEqualToString:[MenuItem defaultItemNameLocalized]];
}
+/**
+ Return a sibling that precedes self, or nil if one wasn't found.
+ */
+- (nullable MenuItem *)precedingSiblingInOrderedItems:(NSOrderedSet *)orderedItems
+{
+ for (NSUInteger idx = [orderedItems indexOfObject:self]; idx > 0; idx--) {
+ MenuItem *previousItem = [orderedItems objectAtIndex:idx - 1];
+ if (previousItem.parent == self.parent) {
+ return previousItem;
+ }
+ }
+ return nil;
+}
+
@end
diff --git a/WordPress/Classes/Models/NewsItem.swift b/WordPress/Classes/Models/NewsItem.swift
deleted file mode 100644
index 7ab97dcb562f..000000000000
--- a/WordPress/Classes/Models/NewsItem.swift
+++ /dev/null
@@ -1,41 +0,0 @@
-/// Encapsulates the content of the message to be presented in the "New" Card
-struct NewsItem {
- let title: String
- let content: String
- let extendedInfoURL: URL
- let version: Decimal
-}
-
-extension NewsItem {
- private struct FileKeys {
- static let title = "Title"
- static let content = "Content"
- static let URL = "URL"
- static let version = "version"
- }
-
- init?(fileContent: [String: String]) {
- guard let title = fileContent[FileKeys.title],
- let content = fileContent[FileKeys.content],
- let urlString = fileContent[FileKeys.URL],
- let url = URL(string: urlString),
- let versionString = fileContent[FileKeys.version],
- let version = Decimal(string: versionString) else {
- return nil
- }
-
- self.init(title: title, content: content, extendedInfoURL: url, version: version)
- }
-}
-
-extension NewsItem: CustomStringConvertible {
- var description: String {
- return "\(title): \(content)"
- }
-}
-
-extension NewsItem: CustomDebugStringConvertible {
- var debugDescription: String {
- return description
- }
-}
diff --git a/WordPress/Classes/Models/Notifications/Actions/ApproveComment.swift b/WordPress/Classes/Models/Notifications/Actions/ApproveComment.swift
index 7db152b5a2d7..ff5f41b9092c 100644
--- a/WordPress/Classes/Models/Notifications/Actions/ApproveComment.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/ApproveComment.swift
@@ -1,8 +1,8 @@
-/// Encapsulates logic to approve a cooment
+/// Encapsulates logic to approve a comment
class ApproveComment: DefaultNotificationActionCommand {
enum TitleStrings {
- static let approve = NSLocalizedString("Approve", comment: "Approves a Comment")
- static let unapprove = NSLocalizedString("Unapprove", comment: "Unapproves a Comment")
+ static let approve = NSLocalizedString("Approve Comment", comment: "Approves a Comment")
+ static let unapprove = NSLocalizedString("Unapprove Comment", comment: "Unapproves a Comment")
}
enum TitleHints {
@@ -18,8 +18,11 @@ class ApproveComment: DefaultNotificationActionCommand {
return on ? .neutral(.shade30) : .primary
}
- override func execute(context: ActionContext) {
- let block = context.block
+ override func execute(context: ActionContext) {
+ guard let block = context.block as? FormattableCommentContent else {
+ super.execute(context: context)
+ return
+ }
if on {
unApprove(block: block)
} else {
diff --git a/WordPress/Classes/Models/Notifications/Actions/EditComment.swift b/WordPress/Classes/Models/Notifications/Actions/EditComment.swift
index cf332cc2911c..99069efa64d7 100644
--- a/WordPress/Classes/Models/Notifications/Actions/EditComment.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/EditComment.swift
@@ -7,8 +7,11 @@ class EditComment: DefaultNotificationActionCommand {
return EditComment.title
}
- override func execute(context: ActionContext) {
- let block = context.block
+ override func execute(context: ActionContext) {
+ guard let block = context.block as? FormattableCommentContent else {
+ super.execute(context: context)
+ return
+ }
let content = context.content
actionsService?.updateCommentWithBlock(block, content: content, completion: { success in
guard success else {
diff --git a/WordPress/Classes/Models/Notifications/Actions/Follow.swift b/WordPress/Classes/Models/Notifications/Actions/Follow.swift
index 235d3471ebbe..7b2aea8c89d6 100644
--- a/WordPress/Classes/Models/Notifications/Actions/Follow.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/Follow.swift
@@ -13,7 +13,10 @@ final class Follow: DefaultNotificationActionCommand {
return on ? .neutral(.shade30) : .primary
}
- override func execute(context: ActionContext) {
-
+ override func execute(context: ActionContext) {
+ guard let _ = context.block as? FormattableUserContent else {
+ super.execute(context: context)
+ return
+ }
}
}
diff --git a/WordPress/Classes/Models/Notifications/Actions/LikeComment.swift b/WordPress/Classes/Models/Notifications/Actions/LikeComment.swift
index e0e2d61fa218..9dafbd75e380 100644
--- a/WordPress/Classes/Models/Notifications/Actions/LikeComment.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/LikeComment.swift
@@ -14,8 +14,11 @@ class LikeComment: DefaultNotificationActionCommand {
return on ? TitleStrings.like : TitleStrings.unlike
}
- override func execute(context: ActionContext) {
- let block = context.block
+ override func execute(context: ActionContext) {
+ guard let block = context.block as? FormattableCommentContent else {
+ super.execute(context: context)
+ return
+ }
if on {
removeLike(block: block)
} else {
diff --git a/WordPress/Classes/Models/Notifications/Actions/LikePost.swift b/WordPress/Classes/Models/Notifications/Actions/LikePost.swift
index a505d510c6dc..4500ec12c814 100644
--- a/WordPress/Classes/Models/Notifications/Actions/LikePost.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/LikePost.swift
@@ -4,7 +4,10 @@ final class LikePost: DefaultNotificationActionCommand {
return NSLocalizedString("Like", comment: "Like a post.")
}
- override func execute(context: ActionContext) {
-
+ override func execute(context: ActionContext) {
+ guard let _ = context.block as? FormattableCommentContent else {
+ super.execute(context: context)
+ return
+ }
}
}
diff --git a/WordPress/Classes/Models/Notifications/Actions/MarkAsSpam.swift b/WordPress/Classes/Models/Notifications/Actions/MarkAsSpam.swift
index c4bfe31d9637..0923df33f7e6 100644
--- a/WordPress/Classes/Models/Notifications/Actions/MarkAsSpam.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/MarkAsSpam.swift
@@ -7,9 +7,13 @@ class MarkAsSpam: DefaultNotificationActionCommand {
return MarkAsSpam.title
}
- override func execute(context: ActionContext) {
+ override func execute(context: ActionContext) {
+ guard let block = context.block as? FormattableCommentContent else {
+ super.execute(context: context)
+ return
+ }
let request = NotificationDeletionRequest(kind: .spamming, action: { [weak self] requestCompletion in
- self?.actionsService?.spamCommentWithBlock(context.block) { (success) in
+ self?.actionsService?.spamCommentWithBlock(block) { (success) in
requestCompletion(success)
}
})
diff --git a/WordPress/Classes/Models/Notifications/Actions/NotificationAction.swift b/WordPress/Classes/Models/Notifications/Actions/NotificationAction.swift
index f0ea318a3234..223f16f69d08 100644
--- a/WordPress/Classes/Models/Notifications/Actions/NotificationAction.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/NotificationAction.swift
@@ -19,7 +19,7 @@ class DefaultNotificationActionCommand: FormattableContentActionCommand {
}()
private(set) lazy var actionsService: NotificationActionsService? = {
- return NotificationActionsService(managedObjectContext: mainContext!)
+ return NotificationActionsService(coreDataStack: ContextManager.shared)
}()
init(on: Bool) {
diff --git a/WordPress/Classes/Models/Notifications/Actions/ReplyToComment.swift b/WordPress/Classes/Models/Notifications/Actions/ReplyToComment.swift
index 331e6056c7e6..4a267596df05 100644
--- a/WordPress/Classes/Models/Notifications/Actions/ReplyToComment.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/ReplyToComment.swift
@@ -2,13 +2,17 @@
class ReplyToComment: DefaultNotificationActionCommand {
static let title = NSLocalizedString("Reply", comment: "Reply to a comment.")
static let hint = NSLocalizedString("Replies to a comment.", comment: "VoiceOver accessibility hint, informing the user the button can be used to reply to a comment.")
+ static let identifier = "reply-button"
override var actionTitle: String {
return ReplyToComment.title
}
- override func execute(context: ActionContext) {
- let block = context.block
+ override func execute(context: ActionContext) {
+ guard let block = context.block as? FormattableCommentContent else {
+ super.execute(context: context)
+ return
+ }
let content = context.content
actionsService?.replyCommentWithBlock(block, content: content, completion: { success in
guard success else {
diff --git a/WordPress/Classes/Models/Notifications/Actions/TrashComment.swift b/WordPress/Classes/Models/Notifications/Actions/TrashComment.swift
index a200d7481738..1c6963b66a8c 100644
--- a/WordPress/Classes/Models/Notifications/Actions/TrashComment.swift
+++ b/WordPress/Classes/Models/Notifications/Actions/TrashComment.swift
@@ -11,10 +11,14 @@ class TrashComment: DefaultNotificationActionCommand {
return .error
}
- override func execute(context: ActionContext) {
+ override func execute(context: ActionContext) {
+ guard let block = context.block as? FormattableCommentContent else {
+ super.execute(context: context)
+ return
+ }
ReachabilityUtils.onAvailableInternetConnectionDo {
let request = NotificationDeletionRequest(kind: .deletion, action: { [weak self] requestCompletion in
- self?.actionsService?.deleteCommentWithBlock(context.block, completion: { success in
+ self?.actionsService?.deleteCommentWithBlock(block, completion: { success in
requestCompletion(success)
})
})
diff --git a/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataClass.swift b/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataClass.swift
new file mode 100644
index 000000000000..702cb7bd202c
--- /dev/null
+++ b/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataClass.swift
@@ -0,0 +1,6 @@
+import CoreData
+
+@objc(LikeUser)
+public class LikeUser: NSManagedObject {
+
+}
diff --git a/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataProperties.swift b/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataProperties.swift
new file mode 100644
index 000000000000..2825a1434902
--- /dev/null
+++ b/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataProperties.swift
@@ -0,0 +1,22 @@
+import CoreData
+
+extension LikeUser {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "LikeUser")
+ }
+
+ @NSManaged public var userID: Int64
+ @NSManaged public var username: String
+ @NSManaged public var displayName: String
+ @NSManaged public var primaryBlogID: Int64
+ @NSManaged public var avatarUrl: String
+ @NSManaged public var bio: String
+ @NSManaged public var dateLiked: Date
+ @NSManaged public var dateLikedString: String
+ @NSManaged public var likedSiteID: Int64
+ @NSManaged public var likedPostID: Int64
+ @NSManaged public var likedCommentID: Int64
+ @NSManaged public var preferredBlog: LikeUserPreferredBlog?
+ @NSManaged public var dateFetched: Date
+}
diff --git a/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataClass.swift b/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataClass.swift
new file mode 100644
index 000000000000..25f4aeb19d61
--- /dev/null
+++ b/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataClass.swift
@@ -0,0 +1,6 @@
+import CoreData
+
+@objc(LikeUserPreferredBlog)
+public class LikeUserPreferredBlog: NSManagedObject {
+
+}
diff --git a/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataProperties.swift b/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataProperties.swift
new file mode 100644
index 000000000000..bf2f60879214
--- /dev/null
+++ b/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataProperties.swift
@@ -0,0 +1,15 @@
+import CoreData
+
+extension LikeUserPreferredBlog {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "LikeUserPreferredBlog")
+ }
+
+ @NSManaged public var blogUrl: String
+ @NSManaged public var blogName: String
+ @NSManaged public var iconUrl: String
+ @NSManaged public var blogID: Int64
+ @NSManaged public var user: LikeUser
+
+}
diff --git a/WordPress/Classes/Models/Notifications/Notification.swift b/WordPress/Classes/Models/Notifications/Notification.swift
index 5be28f4c5d44..3b763d953f44 100644
--- a/WordPress/Classes/Models/Notifications/Notification.swift
+++ b/WordPress/Classes/Models/Notifications/Notification.swift
@@ -81,10 +81,32 @@ class Notification: NSManagedObject {
///
fileprivate var cachedHeaderAndBodyContentGroups: [FormattableContentGroup]?
+ private var cachedAttributesObserver: NotificationCachedAttributesObserver?
+
/// Array that contains the Cached Property Names
///
fileprivate static let cachedAttributes = Set(arrayLiteral: "body", "header", "subject", "timestamp")
+ override func awakeFromFetch() {
+ super.awakeFromFetch()
+
+ if cachedAttributesObserver == nil {
+ let observer = NotificationCachedAttributesObserver()
+ for attr in Notification.cachedAttributes {
+ addObserver(observer, forKeyPath: attr, options: [.prior], context: nil)
+ }
+ cachedAttributesObserver = observer
+ }
+ }
+
+ deinit {
+ if let observer = cachedAttributesObserver {
+ for attr in Notification.cachedAttributes {
+ removeObserver(observer, forKeyPath: attr)
+ }
+ }
+ }
+
func renderSubject() -> NSAttributedString? {
guard let subjectContent = subjectContentGroup?.blocks.first else {
return nil
@@ -99,26 +121,6 @@ class Notification: NSManagedObject {
return formatter.render(content: snippetContent, with: SnippetsContentStyles())
}
- /// When needed, nukes cached attributes
- ///
- override func willChangeValue(forKey key: String) {
- super.willChangeValue(forKey: key)
-
- // Note:
- // Cached Attributes are only consumed on the main thread, when initializing UI elements.
- // As an optimization, we'll only reset those attributes when we're running on the main thread.
- //
- guard managedObjectContext?.concurrencyType == .mainQueueConcurrencyType else {
- return
- }
-
- guard Swift.type(of: self).cachedAttributes.contains(key) else {
- return
- }
-
- resetCachedAttributes()
- }
-
/// Nukes any cached values.
///
func resetCachedAttributes() {
@@ -216,6 +218,10 @@ extension Notification {
return block.isActionEnabled(id: commandId) && !block.isActionOn(id: commandId)
}
+ var isViewMilestone: Bool {
+ return FeatureFlag.milestoneNotifications.enabled && type == "view_milestone"
+ }
+
/// Returns the Meta ID's collection, if any.
///
fileprivate var metaIds: [String: AnyObject]? {
@@ -228,6 +234,18 @@ extension Notification {
return metaIds?[MetaKeys.Comment] as? NSNumber
}
+ /// Comment Author ID, if any.
+ ///
+ @objc var metaCommentAuthorID: NSNumber? {
+ return metaIds?[MetaKeys.User] as? NSNumber
+ }
+
+ /// Comment Parent ID, if any.
+ ///
+ @objc var metaParentID: NSNumber? {
+ return metaIds?[MetaKeys.Parent] as? NSNumber
+ }
+
/// Post ID, if any.
///
@objc var metaPostID: NSNumber? {
@@ -382,6 +400,8 @@ extension Notification {
static let Site = "site"
static let Post = "post"
static let Comment = "comment"
+ static let User = "user"
+ static let Parent = "parent_comment"
static let Reply = "reply_comment"
static let Home = "home"
}
@@ -394,3 +414,25 @@ extension Notification: Notifiable {
return notificationId
}
}
+
+private class NotificationCachedAttributesObserver: NSObject {
+ override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
+ guard let keyPath, let notification = object as? Notification, Notification.cachedAttributes.contains(keyPath) else {
+ return
+ }
+
+ guard (change?[.notificationIsPriorKey] as? NSNumber)?.boolValue == true else {
+ return
+ }
+
+ // Note:
+ // Cached Attributes are only consumed on the main thread, when initializing UI elements.
+ // As an optimization, we'll only reset those attributes when we're running on the main thread.
+ //
+ guard notification.managedObjectContext?.concurrencyType == .mainQueueConcurrencyType else {
+ return
+ }
+
+ notification.resetCachedAttributes()
+ }
+}
diff --git a/WordPress/Classes/Models/Notifications/NotificationBlockGroup.swift b/WordPress/Classes/Models/Notifications/NotificationBlockGroup.swift
deleted file mode 100644
index 45b519ba9b3d..000000000000
--- a/WordPress/Classes/Models/Notifications/NotificationBlockGroup.swift
+++ /dev/null
@@ -1,198 +0,0 @@
-import Foundation
-
-// MARK: - NotificationBlockGroup: Adapter to match 1 View <> 1 BlockGroup
-//
-class NotificationBlockGroup {
- /// Grouped Blocks
- ///
- let blocks: [NotificationBlock]
-
- /// Kind of the current Group
- ///
- let kind: Kind
-
- /// Designated Initializer
- ///
- init(blocks: [NotificationBlock], kind: Kind) {
- self.blocks = blocks
- self.kind = kind
- }
-}
-
-
-
-// MARK: - Helpers Methods
-//
-extension NotificationBlockGroup {
- /// Returns the First Block of a specified kind
- ///
- func blockOfKind(_ kind: NotificationBlock.Kind) -> NotificationBlock? {
- return type(of: self).blockOfKind(kind, from: blocks)
- }
-
- /// Extracts all of the imageUrl's for the blocks of the specified kinds
- ///
- func imageUrlsFromBlocksInKindSet(_ kindSet: Set) -> Set {
- let filtered = blocks.filter { kindSet.contains($0.kind) }
- let imageUrls = filtered.flatMap { $0.imageUrls }
- return Set(imageUrls) as Set
- }
-}
-
-
-
-// MARK: - Parsers
-//
-extension NotificationBlockGroup {
- /// Subject: Contains a User + Text Block
- ///
- class func groupFromSubject(_ subject: [[String: AnyObject]], parent: Notification) -> NotificationBlockGroup {
- let blocks = NotificationBlock.blocksFromArray(subject, parent: parent)
- return NotificationBlockGroup(blocks: blocks, kind: .subject)
- }
-
- /// Header: Contains a User + Text Block
- ///
- class func groupFromHeader(_ header: [[String: AnyObject]], parent: Notification) -> NotificationBlockGroup {
- let blocks = NotificationBlock.blocksFromArray(header, parent: parent)
- return NotificationBlockGroup(blocks: blocks, kind: .header)
- }
-
- /// Body: May contain different kinds of Groups!
- ///
- class func groupsFromBody(_ body: [[String: AnyObject]], parent: Notification) -> [NotificationBlockGroup] {
- let blocks = NotificationBlock.blocksFromArray(body, parent: parent)
-
- switch parent.kind {
- case .comment:
- return groupsForCommentBodyBlocks(blocks, parent: parent)
- default:
- return groupsForNonCommentBodyBlocks(blocks, parent: parent)
- }
- }
-}
-
-
-// MARK: - Private Parsing Helpers
-//
-private extension NotificationBlockGroup {
- /// Non-Comment Body Groups: 1-1 Mapping between Blocks <> BlockGroups
- ///
- /// - Notifications of the kind [Follow, Like, CommentLike] may contain a Footer block.
- /// - We can assume that whenever the last block is of the type .Text, we're dealing with a footer.
- /// - Whenever we detect such a block, we'll map the NotificationBlock into a .Footer group.
- /// - Footers are visually represented as `View All Followers` / `View All Likers`
- ///
- class func groupsForNonCommentBodyBlocks(_ blocks: [NotificationBlock], parent: Notification) -> [NotificationBlockGroup] {
- let parentKindsWithFooters: [NotificationKind] = [.follow, .like, .commentLike]
- let parentMayContainFooter = parentKindsWithFooters.contains(parent.kind)
-
- return blocks.map { block in
- let isFooter = parentMayContainFooter && block.kind == .text && blocks.last == block
- let kind = isFooter ? .footer : Kind.fromBlockKind(block.kind)
- return NotificationBlockGroup(blocks: [block], kind: kind)
- }
- }
-
- /// Comment Body Blocks:
- /// - Required to always render the Actions at the very bottom.
- /// - Adapter: a single NotificationBlockGroup can be easily mapped against a single UI entity.
- ///
- class func groupsForCommentBodyBlocks(_ blocks: [NotificationBlock], parent: Notification) -> [NotificationBlockGroup] {
- guard let comment = blockOfKind(.comment, from: blocks), let user = blockOfKind(.user, from: blocks) else {
- return []
- }
-
- var groups = [NotificationBlockGroup]()
- let commentGroupBlocks = [comment, user]
- let middleGroupBlocks = blocks.filter { return commentGroupBlocks.contains($0) == false }
- let actionGroupBlocks = [comment]
-
- // Comment Group: Comment + User Blocks
- groups.append(NotificationBlockGroup(blocks: commentGroupBlocks, kind: .comment))
-
- // Middle Group(s): Anything
- for block in middleGroupBlocks {
- // Duck Typing Again:
- // If the block contains a range that matches with the metaReplyID field, we'll need to render this
- // with a custom style. Translates into the `You replied to this comment` footer.
- //
- var kind = Kind.fromBlockKind(block.kind)
- if let parentReplyID = parent.metaReplyID, block.notificationRangeWithCommentId(parentReplyID) != nil {
- kind = .footer
- }
-
- groups.append(NotificationBlockGroup(blocks: [block], kind: kind))
- }
-
- // Whenever Possible *REMOVE* this workaround. Pingback Notifications require a locally generated block.
- //
- if parent.isPingback, let homeURL = user.metaLinksHome {
- let blockGroup = pingbackReadMoreGroup(for: homeURL)
- groups.append(blockGroup)
- }
-
- // Actions Group: A copy of the Comment Block (Actions)
- groups.append(NotificationBlockGroup(blocks: actionGroupBlocks, kind: .actions))
-
- return groups
- }
-
- /// Returns the First Block of a specified kind.
- ///
- class func blockOfKind(_ kind: NotificationBlock.Kind, from blocks: [NotificationBlock]) -> NotificationBlock? {
- for block in blocks where block.kind == kind {
- return block
- }
-
- return nil
- }
-}
-
-
-// MARK: - Private Parsing Helpers
-//
-private extension NotificationBlockGroup {
-
- /// Returns a BlockGroup containing a single Text Block, which links to the specified URL.
- ///
- class func pingbackReadMoreGroup(for url: URL) -> NotificationBlockGroup {
- let text = NSLocalizedString("Read the source post", comment: "Displayed at the footer of a Pingback Notification.")
- let textRange = NSRange(location: 0, length: text.count)
- let zeroRange = NSRange(location: 0, length: 0)
-
- let ranges = [
- NotificationRange(kind: .Noticon, range: zeroRange, value: "\u{f442}"),
- NotificationRange(kind: .Link, range: textRange, url: url)
- ]
-
- let block = NotificationBlock(text: text, ranges: ranges)
- return NotificationBlockGroup(blocks: [block], kind: .footer)
- }
-}
-
-// MARK: - NotificationBlockGroup Types
-//
-extension NotificationBlockGroup {
- /// Known Kinds of Block Groups
- ///
- enum Kind {
- case text
- case image
- case user
- case comment
- case actions
- case subject
- case header
- case footer
-
- static func fromBlockKind(_ blockKind: NotificationBlock.Kind) -> Kind {
- switch blockKind {
- case .text: return .text
- case .image: return .image
- case .user: return .user
- case .comment: return .comment
- }
- }
- }
-}
diff --git a/WordPress/Classes/Models/Notifications/NotificationSettings.swift b/WordPress/Classes/Models/Notifications/NotificationSettings.swift
index 02d03be9320e..9cbeb33fabc0 100644
--- a/WordPress/Classes/Models/Notifications/NotificationSettings.swift
+++ b/WordPress/Classes/Models/Notifications/NotificationSettings.swift
@@ -19,6 +19,11 @@ open class NotificationSettings {
///
public let blog: Blog?
+ /// The settings that are stored locally
+ ///
+ static let locallyStoredKeys: [String] = [
+ Keys.weeklyRoundup,
+ ]
/// Designated Initializer
@@ -47,6 +52,10 @@ open class NotificationSettings {
return Keys.localizedDetailsMap[preferenceKey]
}
+ static func isLocallyStored(_ preferenceKey: String) -> Bool {
+ return Self.locallyStoredKeys.contains(preferenceKey)
+ }
+
/// Returns an array of the sorted Preference Keys
///
@@ -130,7 +139,15 @@ open class NotificationSettings {
}
// MARK: - Private Properties
- fileprivate let blogPreferenceKeys = [Keys.commentAdded, Keys.commentLiked, Keys.postLiked, Keys.follower, Keys.achievement, Keys.mention]
+ fileprivate var blogPreferenceKeys: [String] {
+ var keys = [Keys.commentAdded, Keys.commentLiked, Keys.postLiked, Keys.follower, Keys.achievement, Keys.mention]
+
+ if Feature.enabled(.weeklyRoundup) && JetpackNotificationMigrationService.shared.shouldPresentNotifications() {
+ keys.append(Keys.weeklyRoundup)
+ }
+
+ return keys
+ }
fileprivate let blogEmailPreferenceKeys = [Keys.commentAdded, Keys.commentLiked, Keys.postLiked, Keys.follower, Keys.mention]
fileprivate let otherPreferenceKeys = [Keys.commentLiked, Keys.commentReplied]
fileprivate let wpcomPreferenceKeys = [Keys.marketing, Keys.research, Keys.community]
@@ -147,6 +164,7 @@ open class NotificationSettings {
static let marketing = "marketing"
static let research = "research"
static let community = "community"
+ static let weeklyRoundup = "weekly_roundup"
static let localizedDescriptionMap = [
commentAdded: NSLocalizedString("Comments on my site",
@@ -168,7 +186,9 @@ open class NotificationSettings {
research: NSLocalizedString("Research",
comment: "Setting: WordPress.com Surveys"),
community: NSLocalizedString("Community",
- comment: "Setting: WordPress.com Community")
+ comment: "Setting: WordPress.com Community"),
+ weeklyRoundup: NSLocalizedString("Weekly Roundup",
+ comment: "Setting: indicates if the site reports its Weekly Roundup"),
]
static let localizedDetailsMap = [
diff --git a/WordPress/Classes/Models/Page.swift b/WordPress/Classes/Models/Page.swift
index 226bc419533b..ac8f9440f89a 100644
--- a/WordPress/Classes/Models/Page.swift
+++ b/WordPress/Classes/Models/Page.swift
@@ -61,4 +61,35 @@ class Page: AbstractPost {
hash(for: parentID?.intValue ?? 0)
]
}
+
+ override func availableStatusesForEditing() -> [Any] {
+ if isSiteHomepage && isPublished() {
+ return [PostStatusPublish]
+ }
+ return super.availableStatusesForEditing()
+ }
+
+ // MARK: - Homepage Settings
+
+ @objc var isSiteHomepage: Bool {
+ guard let postID = postID,
+ let homepageID = blog.homepagePageID,
+ let homepageType = blog.homepageType,
+ homepageType == .page else {
+ return false
+ }
+
+ return homepageID == postID.intValue
+ }
+
+ @objc var isSitePostsPage: Bool {
+ guard let postID = postID,
+ let postsPageID = blog.homepagePostsPageID,
+ let homepageType = blog.homepageType,
+ homepageType == .page else {
+ return false
+ }
+
+ return postsPageID == postID.intValue
+ }
}
diff --git a/WordPress/Classes/Models/PageTemplateCategory+CoreDataClass.swift b/WordPress/Classes/Models/PageTemplateCategory+CoreDataClass.swift
new file mode 100644
index 000000000000..4f793089069f
--- /dev/null
+++ b/WordPress/Classes/Models/PageTemplateCategory+CoreDataClass.swift
@@ -0,0 +1,7 @@
+import Foundation
+import CoreData
+
+@objc(PageTemplateCategory)
+public class PageTemplateCategory: NSManagedObject {
+
+}
diff --git a/WordPress/Classes/Models/PageTemplateCategory+CoreDataProperties.swift b/WordPress/Classes/Models/PageTemplateCategory+CoreDataProperties.swift
new file mode 100644
index 000000000000..959f97a6a1f7
--- /dev/null
+++ b/WordPress/Classes/Models/PageTemplateCategory+CoreDataProperties.swift
@@ -0,0 +1,56 @@
+import Foundation
+import CoreData
+
+extension PageTemplateCategory {
+
+ @nonobjc public class func fetchRequest(forBlog blog: Blog, categorySlugs: [String]) -> NSFetchRequest {
+ let request = NSFetchRequest(entityName: "PageTemplateCategory")
+ let blogPredicate = NSPredicate(format: "\(#keyPath(PageTemplateCategory.blog)) == %@", blog)
+ let categoryPredicate = NSPredicate(format: "\(#keyPath(PageTemplateCategory.slug)) IN %@", categorySlugs)
+ request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [blogPredicate, categoryPredicate])
+ return request
+ }
+
+ @nonobjc public class func fetchRequest(forBlog blog: Blog) -> NSFetchRequest {
+ let request = NSFetchRequest(entityName: "PageTemplateCategory")
+ request.predicate = NSPredicate(format: "\(#keyPath(PageTemplateCategory.blog)) == %@", blog)
+ return request
+ }
+
+ @NSManaged public var desc: String?
+ @NSManaged public var emoji: String?
+ @NSManaged public var slug: String
+ @NSManaged public var title: String
+ @NSManaged public var layouts: Set?
+ @NSManaged public var blog: Blog?
+ @NSManaged public var ordinal: Int
+}
+
+// MARK: Generated accessors for layouts
+extension PageTemplateCategory {
+
+ @objc(addLayoutsObject:)
+ @NSManaged public func addToLayouts(_ value: PageTemplateLayout)
+
+ @objc(removeLayoutsObject:)
+ @NSManaged public func removeFromLayouts(_ value: PageTemplateLayout)
+
+ @objc(addLayouts:)
+ @NSManaged public func addToLayouts(_ values: Set)
+
+ @objc(removeLayouts:)
+ @NSManaged public func removeFromLayouts(_ values: Set)
+
+}
+
+extension PageTemplateCategory {
+
+ convenience init(context: NSManagedObjectContext, category: RemoteLayoutCategory, ordinal: Int) {
+ self.init(context: context)
+ slug = category.slug
+ title = category.title
+ desc = category.description
+ emoji = category.emoji
+ self.ordinal = ordinal
+ }
+}
diff --git a/WordPress/Classes/Models/PageTemplateLayout+CoreDataClass.swift b/WordPress/Classes/Models/PageTemplateLayout+CoreDataClass.swift
new file mode 100644
index 000000000000..abc555effb7c
--- /dev/null
+++ b/WordPress/Classes/Models/PageTemplateLayout+CoreDataClass.swift
@@ -0,0 +1,7 @@
+import Foundation
+import CoreData
+
+@objc(PageTemplateLayout)
+public class PageTemplateLayout: NSManagedObject {
+
+}
diff --git a/WordPress/Classes/Models/PageTemplateLayout+CoreDataProperties.swift b/WordPress/Classes/Models/PageTemplateLayout+CoreDataProperties.swift
new file mode 100644
index 000000000000..201af71fd63b
--- /dev/null
+++ b/WordPress/Classes/Models/PageTemplateLayout+CoreDataProperties.swift
@@ -0,0 +1,59 @@
+import Foundation
+import CoreData
+
+extension PageTemplateLayout {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "PageTemplateLayout")
+ }
+
+ @NSManaged public var content: String
+ @NSManaged public var preview: String
+ @NSManaged public var previewTablet: String
+ @NSManaged public var previewMobile: String
+ @NSManaged public var demoUrl: String
+ @NSManaged public var slug: String
+ @NSManaged public var title: String?
+ @NSManaged public var categories: Set?
+
+}
+
+// MARK: Generated accessors for categories
+extension PageTemplateLayout {
+
+ @objc(addCategoriesObject:)
+ @NSManaged public func addToCategories(_ value: PageTemplateCategory)
+
+ @objc(removeCategoriesObject:)
+ @NSManaged public func removeFromCategories(_ value: PageTemplateCategory)
+
+ @objc(addCategories:)
+ @NSManaged public func addToCategories(_ values: Set)
+
+ @objc(removeCategories:)
+ @NSManaged public func removeFromCategories(_ values: Set)
+}
+
+extension PageTemplateLayout {
+
+ convenience init(context: NSManagedObjectContext, layout: RemoteLayout) {
+ self.init(context: context)
+ preview = layout.preview ?? ""
+ previewTablet = layout.previewTablet ?? ""
+ previewMobile = layout.previewMobile ?? ""
+ demoUrl = layout.demoUrl ?? ""
+ content = layout.content ?? ""
+ title = layout.title
+ slug = layout.slug
+ }
+}
+
+extension PageTemplateLayout: Comparable {
+ public static func < (lhs: PageTemplateLayout, rhs: PageTemplateLayout) -> Bool {
+ return lhs.slug.compare(rhs.slug) == .orderedDescending
+ }
+
+ public static func > (lhs: PageTemplateLayout, rhs: PageTemplateLayout) -> Bool {
+ return lhs.slug.compare(rhs.slug) == .orderedAscending
+ }
+}
diff --git a/WordPress/Classes/Models/Plan.swift b/WordPress/Classes/Models/Plan.swift
index eb501089b21e..dac0c31b1fa7 100644
--- a/WordPress/Classes/Models/Plan.swift
+++ b/WordPress/Classes/Models/Plan.swift
@@ -11,4 +11,7 @@ public class Plan: NSManagedObject {
@NSManaged public var summary: String
@NSManaged public var features: String
@NSManaged public var icon: String
+ @NSManaged public var supportPriority: Int16
+ @NSManaged public var supportName: String
+ @NSManaged public var nonLocalizedShortname: String
}
diff --git a/WordPress/Classes/Models/Plugin.swift b/WordPress/Classes/Models/Plugin.swift
index bb261db583aa..052c6f85d6c4 100644
--- a/WordPress/Classes/Models/Plugin.swift
+++ b/WordPress/Classes/Models/Plugin.swift
@@ -2,7 +2,7 @@ import Foundation
struct Plugin: Equatable {
let state: PluginState
- let directoryEntry: PluginDirectoryEntry?
+ var directoryEntry: PluginDirectoryEntry?
var id: String {
return state.id
@@ -12,6 +12,13 @@ struct Plugin: Equatable {
return state.name
}
+ var deactivateAllowed: Bool {
+ guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else {
+ return true
+ }
+ return state.deactivateAllowed
+ }
+
static func ==(lhs: Plugin, rhs: Plugin) -> Bool {
return lhs.state == rhs.state
&& lhs.directoryEntry == rhs.directoryEntry
diff --git a/WordPress/Classes/Models/Post+CoreDataProperties.swift b/WordPress/Classes/Models/Post+CoreDataProperties.swift
index e986c6ce6586..1fcad17690ce 100644
--- a/WordPress/Classes/Models/Post+CoreDataProperties.swift
+++ b/WordPress/Classes/Models/Post+CoreDataProperties.swift
@@ -5,10 +5,7 @@ extension Post {
@NSManaged var commentCount: NSNumber?
@NSManaged var disabledPublicizeConnections: [NSNumber: [String: String]]?
- @NSManaged var geolocation: Coordinate?
- @NSManaged var latitudeID: String?
@NSManaged var likeCount: NSNumber?
- @NSManaged var longitudeID: String?
@NSManaged var postFormat: String?
@NSManaged var postType: String?
@NSManaged var publicID: String?
@@ -18,6 +15,10 @@ extension Post {
@NSManaged var categories: Set?
@NSManaged var isStickyPost: Bool
+
+ // If the post is created as an answer to a Blogging Prompt, the promptID is stored here.
+ @NSManaged var bloggingPromptID: String?
+
// These were added manually, since the code generator for Swift is not generating them.
//
@NSManaged func addCategoriesObject(_ value: PostCategory)
diff --git a/WordPress/Classes/Models/Post+RefreshStatus.swift b/WordPress/Classes/Models/Post+RefreshStatus.swift
new file mode 100644
index 000000000000..b8d1b4dc5ec0
--- /dev/null
+++ b/WordPress/Classes/Models/Post+RefreshStatus.swift
@@ -0,0 +1,21 @@
+extension Post {
+
+ /// This method checks the status of all post objects and updates them to the correct status if needed.
+ /// The main cause of wrong status is the app being killed while uploads of posts are happening.
+ ///
+ /// - Parameters:
+ /// - onCompletion: block to invoke when status update is finished.
+ /// - onError: block to invoke if any error occurs while the update is being made.
+ static func refreshStatus(with coreDataStack: CoreDataStack) {
+ coreDataStack.performAndSave { context in
+ let fetch = NSFetchRequest(entityName: Post.classNameWithoutNamespaces())
+ let pushingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: AbstractPostRemoteStatus.pushing.rawValue))
+ let processingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: AbstractPostRemoteStatus.pushingMedia.rawValue))
+ fetch.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [pushingPredicate, processingPredicate])
+ guard let postsPushing = try? context.fetch(fetch) else { return }
+ for post in postsPushing {
+ post.markAsFailedAndDraftIfNeeded()
+ }
+ }
+ }
+}
diff --git a/WordPress/Classes/Models/Post.swift b/WordPress/Classes/Models/Post.swift
index 8e956b6c2b95..3e92adb3fed1 100644
--- a/WordPress/Classes/Models/Post.swift
+++ b/WordPress/Classes/Models/Post.swift
@@ -60,7 +60,7 @@ class Post: AbstractPost {
return
}
- buildContentPreview()
+ storedContentPreviewForDisplay = ""
}
// MARK: - Content Preview
@@ -224,6 +224,10 @@ class Post: AbstractPost {
return (tags?.trim().count > 0)
}
+ override func authorForDisplay() -> String? {
+ return author ?? blog.account?.displayName
+ }
+
// MARK: - BasePost
override func contentPreviewForDisplay() -> String {
@@ -247,16 +251,6 @@ class Post: AbstractPost {
return true
}
- if let coord1 = geolocation?.coordinate,
- let coord2 = originalPost.geolocation?.coordinate, coord1.latitude != coord2.latitude || coord1.longitude != coord2.longitude {
-
- return true
- }
-
- if (geolocation == nil && originalPost.geolocation != nil) || (geolocation != nil && originalPost.geolocation == nil) {
- return true
- }
-
if publicizeMessage ?? "" != originalPost.publicizeMessage ?? "" {
return true
}
@@ -318,8 +312,6 @@ class Post: AbstractPost {
hash(for: tags ?? ""),
hash(for: postFormat ?? ""),
hash(for: stringifiedCategories),
- hash(for: geolocation?.latitude ?? 0),
- hash(for: geolocation?.longitude ?? 0),
hash(for: isStickyPost ? 1 : 0)]
}
}
diff --git a/WordPress/Classes/Models/PostCategory+Creation.swift b/WordPress/Classes/Models/PostCategory+Creation.swift
new file mode 100644
index 000000000000..fe073ec26caa
--- /dev/null
+++ b/WordPress/Classes/Models/PostCategory+Creation.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+extension PostCategory {
+
+ static func create(withBlogID id: NSManagedObjectID, in context: NSManagedObjectContext) throws -> PostCategory {
+ let object = try context.existingObject(with: id)
+
+ guard let blog = object as? Blog else {
+ fatalError("The object id does not belong to a Blog: \(id)")
+ }
+
+ let category = PostCategory(context: context)
+ category.blog = blog
+ return category
+ }
+
+ @objc(createWithBlogObjectID:inContext:)
+ static func objc_create(withBlogID id: NSManagedObjectID, in context: NSManagedObjectContext) -> PostCategory? {
+ try? create(withBlogID: id, in: context)
+ }
+
+}
diff --git a/WordPress/Classes/Models/PostCategory+Lookup.swift b/WordPress/Classes/Models/PostCategory+Lookup.swift
new file mode 100644
index 000000000000..98b1db9ce9c4
--- /dev/null
+++ b/WordPress/Classes/Models/PostCategory+Lookup.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+extension PostCategory {
+
+ static func lookup(withBlogID id: NSManagedObjectID, categoryID: NSNumber, in context: NSManagedObjectContext) throws -> PostCategory? {
+ try lookup(withBlogID: id, predicate: NSPredicate(format: "categoryID == %@", categoryID), in: context)
+ }
+
+ static func lookup(withBlogID id: NSManagedObjectID, parentCategoryID: NSNumber?, categoryName: String, in context: NSManagedObjectContext) throws -> PostCategory? {
+ try lookup(
+ withBlogID: id,
+ predicate: NSPredicate(format: "(categoryName like %@) AND (parentID = %@)", categoryName, parentCategoryID ?? 0),
+ in: context
+ )
+ }
+
+ private static func lookup(withBlogID id: NSManagedObjectID, predicate: NSPredicate, in context: NSManagedObjectContext) throws -> PostCategory? {
+ let object = try context.existingObject(with: id)
+
+ guard let blog = object as? Blog else {
+ fatalError("The object id does not belong to a Blog: \(id)")
+ }
+
+ return blog.categories?.first { predicate.evaluate(with: $0) } as? PostCategory
+ }
+
+}
+
+// MARK: - Objective-C API
+
+extension PostCategory {
+
+ @objc(lookupWithBlogObjectID:categoryID:inContext:)
+ static func objc_lookup(withBlogID id: NSManagedObjectID, categoryID: NSNumber, in context: NSManagedObjectContext) -> PostCategory? {
+ try? lookup(withBlogID: id, categoryID: categoryID, in: context)
+ }
+
+ @objc(lookupWithBlogObjectID:parentCategoryID:categoryName:inContext:)
+ static func objc_lookup(withBlogID id: NSManagedObjectID, parentCategoryID: NSNumber?, categoryName: String, in context: NSManagedObjectContext) -> PostCategory? {
+ try? lookup(withBlogID: id, parentCategoryID: parentCategoryID, categoryName: categoryName, in: context)
+ }
+
+}
diff --git a/WordPress/Classes/Models/PostContentProvider.h b/WordPress/Classes/Models/PostContentProvider.h
index 417ae33be7bb..66ec522bd313 100644
--- a/WordPress/Classes/Models/PostContentProvider.h
+++ b/WordPress/Classes/Models/PostContentProvider.h
@@ -3,16 +3,17 @@
@protocol PostContentProvider
- (NSString *)titleForDisplay;
- (NSString *)authorForDisplay;
-- (NSString *)blogNameForDisplay;
-- (NSString *)statusForDisplay;
- (NSString *)contentForDisplay;
- (NSString *)contentPreviewForDisplay;
- (NSURL *)avatarURLForDisplay; // Some providers use a hardcoded URL or blavatar URL
- (NSString *)gravatarEmailForDisplay;
- (NSDate *)dateForDisplay;
@optional
+- (NSString *)blogNameForDisplay;
+- (NSString *)statusForDisplay;
- (BOOL)unreadStatusForDisplay;
- (NSURL *)featuredImageURLForDisplay;
- (NSURL *)authorURL;
- (NSString *)slugForDisplay;
+- (NSArray *)tagsForDisplay;
@end
diff --git a/WordPress/Classes/Models/PostTag+Comparable.swift b/WordPress/Classes/Models/PostTag+Comparable.swift
deleted file mode 100644
index 8bb2107aea2d..000000000000
--- a/WordPress/Classes/Models/PostTag+Comparable.swift
+++ /dev/null
@@ -1,10 +0,0 @@
-
-extension PostTag: Comparable {
- public static func <(lhs: PostTag, rhs: PostTag) -> Bool {
- guard let lhsName = lhs.name, let rhsName = rhs.name else {
- return false
- }
-
- return lhsName < rhsName
- }
-}
diff --git a/WordPress/Classes/Models/PublicizeConnection+Creation.swift b/WordPress/Classes/Models/PublicizeConnection+Creation.swift
new file mode 100644
index 000000000000..0d993f265670
--- /dev/null
+++ b/WordPress/Classes/Models/PublicizeConnection+Creation.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+extension PublicizeConnection {
+
+ /// Composes a new `PublicizeConnection`, or updates an existing one, with
+ /// data represented by the passed `RemotePublicizeConnection`.
+ ///
+ /// - Parameter remoteConnection: The remote connection representing the publicize connection.
+ ///
+ /// - Returns: A `PublicizeConnection`.
+ ///
+ static func createOrReplace(from remoteConnection: RemotePublicizeConnection, in context: NSManagedObjectContext) -> PublicizeConnection {
+ let pubConnection = findPublicizeConnection(byID: remoteConnection.connectionID, in: context)
+ ?? NSEntityDescription.insertNewObject(forEntityName: PublicizeConnection.classNameWithoutNamespaces(),
+ into: context) as! PublicizeConnection
+
+ pubConnection.connectionID = remoteConnection.connectionID
+ pubConnection.dateExpires = remoteConnection.dateExpires
+ pubConnection.dateIssued = remoteConnection.dateIssued
+ pubConnection.externalDisplay = remoteConnection.externalDisplay
+ pubConnection.externalFollowerCount = remoteConnection.externalFollowerCount
+ pubConnection.externalID = remoteConnection.externalID
+ pubConnection.externalName = remoteConnection.externalName
+ pubConnection.externalProfilePicture = remoteConnection.externalProfilePicture
+ pubConnection.externalProfileURL = remoteConnection.externalProfileURL
+ pubConnection.keyringConnectionID = remoteConnection.keyringConnectionID
+ pubConnection.keyringConnectionUserID = remoteConnection.keyringConnectionUserID
+ pubConnection.label = remoteConnection.label
+ pubConnection.refreshURL = remoteConnection.refreshURL
+ pubConnection.service = remoteConnection.service
+ pubConnection.shared = remoteConnection.shared
+ pubConnection.status = remoteConnection.status
+ pubConnection.siteID = remoteConnection.siteID
+ pubConnection.userID = remoteConnection.userID
+
+ return pubConnection
+ }
+
+ /// Finds a cached `PublicizeConnection` by its `connectionID`
+ ///
+ /// - Parameter connectionID: The ID of the `PublicizeConnection`.
+ ///
+ /// - Returns: The requested `PublicizeConnection` or nil.
+ ///
+ private static func findPublicizeConnection(byID connectionID: NSNumber, in context: NSManagedObjectContext) -> PublicizeConnection? {
+ let request = NSFetchRequest(entityName: PublicizeConnection.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "connectionID = %@", connectionID)
+ return try? context.fetch(request).first
+ }
+
+}
diff --git a/WordPress/Classes/Models/PublicizeService+Lookup.swift b/WordPress/Classes/Models/PublicizeService+Lookup.swift
new file mode 100644
index 000000000000..09884c044ecc
--- /dev/null
+++ b/WordPress/Classes/Models/PublicizeService+Lookup.swift
@@ -0,0 +1,30 @@
+extension PublicizeService {
+ /// Finds a cached `PublicizeService` matching the specified service name.
+ ///
+ /// - Parameter name: The name of the service. This is the `serviceID` attribute for a `PublicizeService` object.
+ ///
+ /// - Returns: The requested `PublicizeService` or nil.
+ ///
+ static func lookupPublicizeServiceNamed(_ name: String, in context: NSManagedObjectContext) throws -> PublicizeService? {
+ let request = NSFetchRequest(entityName: PublicizeService.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "serviceID = %@", name)
+ return try context.fetch(request).first
+ }
+
+ @objc(lookupPublicizeServiceNamed:inContext:)
+ static func objc_lookupPublicizeServiceNamed(_ name: String, in context: NSManagedObjectContext) -> PublicizeService? {
+ try? lookupPublicizeServiceNamed(name, in: context)
+ }
+
+ /// Returns an array of all cached `PublicizeService` objects.
+ ///
+ /// - Returns: An array of `PublicizeService`. The array is empty if no objects are cached.
+ ///
+ @objc(allPublicizeServicesInContext:error:)
+ static func allPublicizeServices(in context: NSManagedObjectContext) throws -> [PublicizeService] {
+ let request = NSFetchRequest(entityName: PublicizeService.classNameWithoutNamespaces())
+ let sortDescriptor = NSSortDescriptor(key: "order", ascending: true)
+ request.sortDescriptors = [sortDescriptor]
+ return try context.fetch(request)
+ }
+}
diff --git a/WordPress/Classes/Models/ReaderAbstractTopic+Lookup.swift b/WordPress/Classes/Models/ReaderAbstractTopic+Lookup.swift
new file mode 100644
index 000000000000..e973d1cb8d65
--- /dev/null
+++ b/WordPress/Classes/Models/ReaderAbstractTopic+Lookup.swift
@@ -0,0 +1,90 @@
+import Foundation
+
+extension ReaderAbstractTopic {
+
+ /// Fetch all `ReaderAbstractTopics` currently in Core Data.
+ ///
+ /// - Returns: An array of all `ReaderAbstractTopics` currently persisted in Core Data.
+ @objc(lookupAllInContext:error:)
+ static func lookupAll(in context: NSManagedObjectContext) throws -> [ReaderAbstractTopic] {
+ let request = NSFetchRequest(entityName: ReaderAbstractTopic.classNameWithoutNamespaces())
+ return try context.fetch(request)
+ }
+
+ /// Fetch all `ReaderAbstractTopics` for the menu currently in Core Data.
+ ///
+ /// - Returns: An array of all `ReaderAbstractTopics` for the menu currently persisted in Core Data.
+ @objc(lookupAllMenusInContext:error:)
+ static func lookupAllMenus(in context: NSManagedObjectContext) throws -> [ReaderAbstractTopic] {
+ let request = NSFetchRequest(entityName: ReaderAbstractTopic.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "showInMenu = YES")
+ return try context.fetch(request)
+ }
+
+ /// Fetch all `Fetch all saved Site topics` currently in Core Data.
+ ///
+ @objc(lookupAllSitesInContext:error:)
+ static func lookupAllSites(in context: NSManagedObjectContext) throws -> [ReaderSiteTopic] {
+ let request = NSFetchRequest(entityName: ReaderSiteTopic.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "following = YES")
+ request.sortDescriptors = [
+ NSSortDescriptor(key: "title", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:)))
+ ]
+ return try context.fetch(request)
+ }
+
+ /// Find a specific ReaderAbstractTopic by its `path` property.
+ ///
+ /// - Parameter path: The unique, cannonical path of the topic.
+ /// - Returns: A matching `ReaderAbstractTopic` or nil if there is no match.
+ static func lookup(withPath path: String, in context: NSManagedObjectContext) throws -> ReaderAbstractTopic? {
+ let lowcasedPath = path.lowercased()
+ return try lookupAll(in: context).first { $0.path == lowcasedPath }
+ }
+
+ /// Find a specific ReaderAbstractTopic by its `path` property.
+ ///
+ /// - Parameter path: The unique, cannonical path of the topic.
+ /// - Returns: A matching `ReaderAbstractTopic` or nil if there is no match.
+ @objc(lookupWithPath:inContext:)
+ static func objc_lookup(withPath path: String, in context: NSManagedObjectContext) -> ReaderAbstractTopic? {
+ try? lookup(withPath: path, in: context)
+ }
+
+ /// Find a topic where its path contains a specified path.
+ ///
+ /// - Parameter path: The path of the topic
+ /// - Returns: A matching abstract topic or nil.
+ static func lookup(pathContaining path: String, in context: NSManagedObjectContext) throws -> ReaderAbstractTopic? {
+ let lowcasedPath = path.lowercased()
+ return try lookupAll(in: context).first { $0.path.contains(lowcasedPath) }
+ }
+
+ /// Find a topic where its path contains a specified path.
+ ///
+ /// - Parameter path: The path of the topic
+ /// - Returns: A matching abstract topic or nil.
+ @objc(lookupContainingPath:inContext:)
+ static func objc_lookup(pathContaining path: String, in context: NSManagedObjectContext) -> ReaderAbstractTopic? {
+ try? lookup(pathContaining: path, in: context)
+ }
+
+ /// Fetch the topic for 'sites I follow' if it exists.
+ ///
+ /// - Returns: A `ReaderAbstractTopic` instance or nil.
+ static func lookupFollowedSitesTopic(in context: NSManagedObjectContext) throws -> ReaderAbstractTopic? {
+ let request = NSFetchRequest(entityName: ReaderAbstractTopic.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "path LIKE %@", "*/read/following")
+ request.fetchLimit = 1
+ return try context.fetch(request).first
+ }
+
+ /// Fetch the topic for 'sites I follow' if it exists.
+ ///
+ /// - Returns: A `ReaderAbstractTopic` instance or nil.
+ @objc(lookupFollowedSitesTopicInContext:)
+ static func objc_lookupFollowedSitesTopic(in context: NSManagedObjectContext) -> ReaderAbstractTopic? {
+ try? lookupFollowedSitesTopic(in: context)
+ }
+
+}
diff --git a/WordPress/Classes/Models/ReaderCard+CoreDataClass.swift b/WordPress/Classes/Models/ReaderCard+CoreDataClass.swift
new file mode 100644
index 000000000000..2787ce87cb49
--- /dev/null
+++ b/WordPress/Classes/Models/ReaderCard+CoreDataClass.swift
@@ -0,0 +1,59 @@
+import Foundation
+import CoreData
+
+public class ReaderCard: NSManagedObject {
+ enum CardType {
+ case post
+ case topics
+ case sites
+ case unknown
+ }
+
+ var type: CardType {
+ if post != nil {
+ return .post
+ }
+
+ if topicsArray.count > 0 {
+ return .topics
+ }
+
+ if sitesArray.count > 0 {
+ return .sites
+ }
+
+ return .unknown
+ }
+
+ var topicsArray: [ReaderTagTopic] {
+ topics?.array as? [ReaderTagTopic] ?? []
+ }
+
+ var sitesArray: [ReaderSiteTopic] {
+ sites?.array as? [ReaderSiteTopic] ?? []
+ }
+
+ convenience init?(context: NSManagedObjectContext, from remoteCard: RemoteReaderCard) {
+ guard remoteCard.type != .unknown else {
+ return nil
+ }
+
+ self.init(context: context)
+
+ switch remoteCard.type {
+ case .post:
+ post = ReaderPost.createOrReplace(fromRemotePost: remoteCard.post, for: nil, context: context)
+ case .interests:
+ topics = NSOrderedSet(array: remoteCard.interests?.map {
+ ReaderTagTopic.createOrUpdateIfNeeded(from: $0, context: context)
+ } ?? [])
+ case .sites:
+ sites = NSOrderedSet(array: remoteCard.sites?.map {
+ ReaderSiteTopic.createIfNeeded(from: $0, context: context)
+ } ?? [])
+
+ default:
+ break
+ }
+ }
+}
diff --git a/WordPress/Classes/Models/ReaderCard+CoreDataProperties.swift b/WordPress/Classes/Models/ReaderCard+CoreDataProperties.swift
new file mode 100644
index 000000000000..5dc390451d3c
--- /dev/null
+++ b/WordPress/Classes/Models/ReaderCard+CoreDataProperties.swift
@@ -0,0 +1,15 @@
+import Foundation
+import CoreData
+
+extension ReaderCard {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "ReaderCard")
+ }
+
+ @NSManaged public var sortRank: Double
+ @NSManaged public var post: ReaderPost?
+ @NSManaged public var topics: NSOrderedSet?
+ @NSManaged public var sites: NSOrderedSet?
+
+}
diff --git a/WordPress/Classes/Models/ReaderCardContent+PostInformation.swift b/WordPress/Classes/Models/ReaderCardContent+PostInformation.swift
deleted file mode 100644
index cbc965310715..000000000000
--- a/WordPress/Classes/Models/ReaderCardContent+PostInformation.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-import Foundation
-
-class ReaderCardContent: ImageSourceInformation {
- private let originalProvider: ReaderPostContentProvider
-
- init(provider: ReaderPostContentProvider) {
- originalProvider = provider
- }
-
- var isPrivateOnWPCom: Bool {
- return originalProvider.isPrivate() && originalProvider.isWPCom()
- }
-
- var isSelfHostedWithCredentials: Bool {
- return !originalProvider.isWPCom() && !originalProvider.isJetpack()
- }
-}
diff --git a/WordPress/Classes/Models/ReaderListTopic+Creation.swift b/WordPress/Classes/Models/ReaderListTopic+Creation.swift
new file mode 100644
index 000000000000..2f0abdfd6e2a
--- /dev/null
+++ b/WordPress/Classes/Models/ReaderListTopic+Creation.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+extension ReaderListTopic {
+
+ /// Returns an existing topic for the specified list, or creates one if one
+ /// doesn't already exist.
+ ///
+ static func named(_ listName: String, forUser user: String, in context: NSManagedObjectContext) -> ReaderListTopic? {
+ let remote = ReaderTopicServiceRemote(wordPressComRestApi: WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress()))
+ let sanitizedListName = remote.slug(forTopicName: listName) ?? listName.lowercased()
+ let sanitizedUser = user.lowercased()
+ let path = remote.path(forEndpoint: "read/list/\(sanitizedUser)/\(sanitizedListName)/posts", withVersion: ._1_2)
+
+ if let existingTopic = try? ReaderAbstractTopic.lookup(pathContaining: path, in: context) as? ReaderListTopic {
+ return existingTopic
+ }
+
+ let topic = ReaderListTopic(context: context)
+ topic.title = listName
+ topic.slug = sanitizedListName
+ topic.owner = user
+ topic.path = path
+
+ return topic
+ }
+
+}
diff --git a/WordPress/Classes/Models/ReaderPost+Helper.swift b/WordPress/Classes/Models/ReaderPost+Helper.swift
new file mode 100644
index 000000000000..b4fe2e3ae71b
--- /dev/null
+++ b/WordPress/Classes/Models/ReaderPost+Helper.swift
@@ -0,0 +1,44 @@
+import Foundation
+
+extension ReaderPost {
+
+ /// Find cached comment with given ID.
+ ///
+ /// - Parameter id: The comment id
+ /// - Returns: The `Comment` object associated with the given id, or `nil` if none is found.
+ @objc
+ func comment(withID id: NSNumber) -> Comment? {
+ comment(withID: id.int32Value)
+ }
+
+ /// Find cached comment with given ID.
+ ///
+ /// - Parameter id: The comment id
+ /// - Returns: The `Comment` object associated with the given id, or `nil` if none is found.
+ func comment(withID id: Int32) -> Comment? {
+ return (comments as? Set)?.first { $0.commentID == id }
+ }
+
+ /// Get a cached site's ReaderPost with the specified ID.
+ ///
+ /// - Parameter postID: ID of the post.
+ /// - Parameter siteID: ID of the site the post belongs to.
+ /// - Returns: the matching `ReaderPost`, or `nil` if none is found.
+ static func lookup(withID postID: NSNumber, forSiteWithID siteID: NSNumber, in context: NSManagedObjectContext) throws -> ReaderPost? {
+ let request = NSFetchRequest(entityName: ReaderPost.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "postID = %@ AND siteID = %@", postID, siteID)
+ request.fetchLimit = 1
+ return try context.fetch(request).first
+ }
+
+ /// Get a cached site's ReaderPost with the specified ID.
+ ///
+ /// - Parameter postID: ID of the post.
+ /// - Parameter siteID: ID of the site the post belongs to.
+ /// - Returns: the matching `ReaderPost`, or `nil` if none is found.
+ @objc(lookupWithID:forSiteWithID:inContext:)
+ static func objc_lookup(withID postID: NSNumber, forSiteWithID siteID: NSNumber, in context: NSManagedObjectContext) -> ReaderPost? {
+ try? lookup(withID: postID, forSiteWithID: siteID, in: context)
+ }
+
+}
diff --git a/WordPress/Classes/Models/ReaderPost.h b/WordPress/Classes/Models/ReaderPost.h
index 9ed8f8b7d1b3..31931f3f7a12 100644
--- a/WordPress/Classes/Models/ReaderPost.h
+++ b/WordPress/Classes/Models/ReaderPost.h
@@ -7,6 +7,8 @@
@class ReaderCrossPostMeta;
@class SourcePostAttribution;
@class Comment;
+@class RemoteReaderPost;
+@class ReaderCard;
extern NSString * const ReaderPostStoredCommentIDKey;
extern NSString * const ReaderPostStoredCommentTextKey;
@@ -26,12 +28,16 @@ extern NSString * const ReaderPostStoredCommentTextKey;
@property (nonatomic, strong) NSNumber *feedID;
@property (nonatomic, strong) NSNumber *feedItemID;
@property (nonatomic, strong) NSString *globalID;
+@property (nonatomic) BOOL isBlogAtomic;
@property (nonatomic) BOOL isBlogPrivate;
@property (nonatomic) BOOL isFollowing;
@property (nonatomic) BOOL isLiked;
@property (nonatomic) BOOL isReblogged;
@property (nonatomic) BOOL isWPCom;
@property (nonatomic) BOOL isSavedForLater;
+@property (nonatomic) BOOL isSeen;
+@property (nonatomic) BOOL isSeenSupported;
+@property (nonatomic, strong) NSNumber *organizationID;
@property (nonatomic, strong) NSNumber *likeCount;
@property (nonatomic, strong) NSNumber *score;
@property (nonatomic, strong) NSNumber *siteID;
@@ -45,10 +51,14 @@ extern NSString * const ReaderPostStoredCommentTextKey;
@property (nonatomic, readonly, strong) NSURL *featuredImageURL;
@property (nonatomic, strong) NSString *tags;
@property (nonatomic, strong) ReaderAbstractTopic *topic;
+@property (nonatomic, strong) NSSet *card;
@property (nonatomic) BOOL isLikesEnabled;
@property (nonatomic) BOOL isSharingEnabled;
@property (nonatomic) BOOL isSiteBlocked;
@property (nonatomic, strong) SourcePostAttribution *sourceAttribution;
+@property (nonatomic) BOOL isSubscribedComments;
+@property (nonatomic) BOOL canSubscribeComments;
+@property (nonatomic) BOOL receivesCommentNotifications;
@property (nonatomic, strong) NSString *primaryTag;
@property (nonatomic, strong) NSString *primaryTagSlug;
@@ -65,8 +75,11 @@ extern NSString * const ReaderPostStoredCommentTextKey;
// When true indicates a post should not be deleted/cleaned-up as its currently being used.
@property (nonatomic) BOOL inUse;
++ (instancetype)createOrReplaceFromRemotePost:(RemoteReaderPost *)remotePost forTopic:(ReaderAbstractTopic *)topic context:(NSManagedObjectContext *) managedObjectContext;
+
- (BOOL)isCrossPost;
- (BOOL)isPrivate;
+- (BOOL)isP2Type;
- (NSString *)authorString;
- (NSString *)avatar;
- (UIImage *)cachedAvatarWithSize:(CGSize)size;
diff --git a/WordPress/Classes/Models/ReaderPost.m b/WordPress/Classes/Models/ReaderPost.m
index 61f1156dbec3..ffd9bea01406 100644
--- a/WordPress/Classes/Models/ReaderPost.m
+++ b/WordPress/Classes/Models/ReaderPost.m
@@ -1,6 +1,6 @@
#import "ReaderPost.h"
#import "AccountService.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "SourcePostAttribution.h"
#import "WPAccount.h"
#import "WPAvatarSource.h"
@@ -12,6 +12,11 @@
NSString * const ReaderPostStoredCommentIDKey = @"commentID";
NSString * const ReaderPostStoredCommentTextKey = @"comment";
+static NSString * const SourceAttributionSiteTaxonomy = @"site-pick";
+static NSString * const SourceAttributionImageTaxonomy = @"image-pick";
+static NSString * const SourceAttributionQuoteTaxonomy = @"quote-pick";
+static NSString * const SourceAttributionStandardTaxonomy = @"standard-pick";
+
@implementation ReaderPost
@dynamic authorDisplayName;
@@ -26,11 +31,13 @@ @implementation ReaderPost
@dynamic featuredImage;
@dynamic feedID;
@dynamic feedItemID;
+@dynamic isBlogAtomic;
@dynamic isBlogPrivate;
@dynamic isFollowing;
@dynamic isLiked;
@dynamic isReblogged;
@dynamic isWPCom;
+@dynamic organizationID;
@dynamic likeCount;
@dynamic score;
@dynamic siteID;
@@ -40,12 +47,18 @@ @implementation ReaderPost
@dynamic comments;
@dynamic tags;
@dynamic topic;
+@dynamic card;
@dynamic globalID;
@dynamic isLikesEnabled;
@dynamic isSharingEnabled;
@dynamic isSiteBlocked;
@dynamic sourceAttribution;
@dynamic isSavedForLater;
+@dynamic isSeen;
+@dynamic isSeenSupported;
+@dynamic isSubscribedComments;
+@dynamic canSubscribeComments;
+@dynamic receivesCommentNotifications;
@dynamic primaryTag;
@dynamic primaryTagSlug;
@@ -59,17 +72,188 @@ @implementation ReaderPost
@synthesize rendered;
++ (instancetype)createOrReplaceFromRemotePost:(RemoteReaderPost *)remotePost
+ forTopic:(ReaderAbstractTopic *)topic
+ context:(NSManagedObjectContext *) managedObjectContext
+{
+ NSError *error;
+ ReaderPost *post;
+ NSString *globalID = remotePost.globalID;
+ NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"];
+ fetchRequest.predicate = [NSPredicate predicateWithFormat:@"globalID = %@ AND (topic = %@ OR topic = NULL)", globalID, topic];
+ NSArray *arr = [managedObjectContext executeFetchRequest:fetchRequest error:&error];
+
+ BOOL existing = false;
+ if (error) {
+ DDLogError(@"Error fetching an existing reader post. - %@", error);
+ } else if ([arr count] > 0) {
+ post = (ReaderPost *)[arr objectAtIndex:0];
+ existing = YES;
+ } else {
+ post = [NSEntityDescription insertNewObjectForEntityForName:@"ReaderPost"
+ inManagedObjectContext:managedObjectContext];
+ }
+
+ post.authorID = remotePost.authorID;
+ post.author = remotePost.author;
+ post.authorAvatarURL = remotePost.authorAvatarURL;
+ post.authorDisplayName = remotePost.authorDisplayName;
+ post.authorEmail = remotePost.authorEmail;
+ post.authorURL = remotePost.authorURL;
+ post.organizationID = remotePost.organizationID;
+ post.siteIconURL = remotePost.siteIconURL;
+ post.blogName = remotePost.blogName;
+ post.blogDescription = remotePost.blogDescription;
+ post.blogURL = remotePost.blogURL;
+ post.commentCount = remotePost.commentCount;
+ post.commentsOpen = remotePost.commentsOpen;
+ post.date_created_gmt = [DateUtils dateFromISOString:remotePost.date_created_gmt];
+ post.featuredImage = remotePost.featuredImage;
+ post.feedID = remotePost.feedID;
+ post.feedItemID = remotePost.feedItemID;
+ post.globalID = remotePost.globalID;
+ post.isBlogAtomic = remotePost.isBlogAtomic;
+ post.isBlogPrivate = remotePost.isBlogPrivate;
+ post.isFollowing = remotePost.isFollowing;
+ post.isLiked = remotePost.isLiked;
+ post.isReblogged = remotePost.isReblogged;
+ post.isWPCom = remotePost.isWPCom;
+ post.organizationID = remotePost.organizationID;
+ post.likeCount = remotePost.likeCount;
+ post.permaLink = remotePost.permalink;
+ post.postID = remotePost.postID;
+ post.postTitle = remotePost.postTitle;
+ post.railcar = remotePost.railcar;
+ post.score = remotePost.score;
+ post.siteID = remotePost.siteID;
+ post.sortDate = remotePost.sortDate;
+ post.isSeen = remotePost.isSeen;
+ post.isSeenSupported = remotePost.isSeenSupported;
+ post.isSubscribedComments = remotePost.isSubscribedComments;
+ post.canSubscribeComments = remotePost.canSubscribeComments;
+ post.receivesCommentNotifications = remotePost.receivesCommentNotifications;
+
+ if (existing && [topic isKindOfClass:[ReaderSearchTopic class]]) {
+ // Failsafe. The `read/search` endpoint might return the same post on
+ // more than one page. If this happens preserve the *original* sortRank
+ // to avoid content jumping around in the UI.
+ } else {
+ post.sortRank = remotePost.sortRank;
+ }
+
+ post.status = remotePost.status;
+ post.summary = remotePost.summary;
+ post.tags = remotePost.tags;
+ post.isSharingEnabled = remotePost.isSharingEnabled;
+ post.isLikesEnabled = remotePost.isLikesEnabled;
+ post.isSiteBlocked = NO;
+
+ if (remotePost.crossPostMeta) {
+ if (!post.crossPostMeta) {
+ ReaderCrossPostMeta *meta = (ReaderCrossPostMeta *)[NSEntityDescription insertNewObjectForEntityForName:[ReaderCrossPostMeta classNameWithoutNamespaces]
+ inManagedObjectContext:managedObjectContext];
+ post.crossPostMeta = meta;
+ }
+ post.crossPostMeta.siteURL = remotePost.crossPostMeta.siteURL;
+ post.crossPostMeta.postURL = remotePost.crossPostMeta.postURL;
+ post.crossPostMeta.commentURL = remotePost.crossPostMeta.commentURL;
+ post.crossPostMeta.siteID = remotePost.crossPostMeta.siteID;
+ post.crossPostMeta.postID = remotePost.crossPostMeta.postID;
+ } else {
+ post.crossPostMeta = nil;
+ }
+
+ NSString *tag = remotePost.primaryTag;
+ NSString *slug = remotePost.primaryTagSlug;
+ if ([topic isKindOfClass:[ReaderTagTopic class]]) {
+ ReaderTagTopic *tagTopic = (ReaderTagTopic *)topic;
+ if ([tagTopic.slug isEqualToString:remotePost.primaryTagSlug]) {
+ tag = remotePost.secondaryTag;
+ slug = remotePost.secondaryTagSlug;
+ }
+ }
+ post.primaryTag = tag;
+ post.primaryTagSlug = slug;
+
+ post.isExternal = remotePost.isExternal;
+ post.isJetpack = remotePost.isJetpack;
+ post.wordCount = remotePost.wordCount;
+ post.readingTime = remotePost.readingTime;
+
+ if (remotePost.sourceAttribution) {
+ post.sourceAttribution = [self createOrReplaceFromRemoteDiscoverAttribution:remotePost.sourceAttribution forPost:post context:managedObjectContext];
+ } else {
+ post.sourceAttribution = nil;
+ }
+
+ post.content = [RichContentFormatter removeInlineStyles:[RichContentFormatter removeForbiddenTags:remotePost.content]];
+
+ // assign the topic last.
+ post.topic = topic;
+
+ return post;
+}
+
++ (SourcePostAttribution *)createOrReplaceFromRemoteDiscoverAttribution:(RemoteSourcePostAttribution *)remoteAttribution
+ forPost:(ReaderPost *)post
+ context:(NSManagedObjectContext *) managedObjectContext
+{
+ SourcePostAttribution *attribution = post.sourceAttribution;
+
+ if (!attribution) {
+ attribution = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([SourcePostAttribution class])
+ inManagedObjectContext:managedObjectContext];
+ }
+ attribution.authorName = remoteAttribution.authorName;
+ attribution.authorURL = remoteAttribution.authorURL;
+ attribution.avatarURL = remoteAttribution.avatarURL;
+ attribution.blogName = remoteAttribution.blogName;
+ attribution.blogURL = remoteAttribution.blogURL;
+ attribution.permalink = remoteAttribution.permalink;
+ attribution.blogID = remoteAttribution.blogID;
+ attribution.postID = remoteAttribution.postID;
+ attribution.commentCount = remoteAttribution.commentCount;
+ attribution.likeCount = remoteAttribution.likeCount;
+ attribution.attributionType = [self attributionTypeFromTaxonomies:remoteAttribution.taxonomies];
+ return attribution;
+}
+
++ (NSString *)attributionTypeFromTaxonomies:(NSArray *)taxonomies
+{
+ if ([taxonomies containsObject:SourceAttributionSiteTaxonomy]) {
+ return SourcePostAttributionTypeSite;
+ }
+
+ if ([taxonomies containsObject:SourceAttributionImageTaxonomy] ||
+ [taxonomies containsObject:SourceAttributionQuoteTaxonomy] ||
+ [taxonomies containsObject:SourceAttributionStandardTaxonomy] ) {
+ return SourcePostAttributionTypePost;
+ }
+
+ return nil;
+}
- (BOOL)isCrossPost
{
return self.crossPostMeta != nil;
}
+- (BOOL)isAtomic
+{
+ return self.isBlogAtomic;
+}
+
- (BOOL)isPrivate
{
return self.isBlogPrivate;
}
+- (BOOL)isP2Type
+{
+ NSInteger orgID = [self.organizationID intValue];
+ return orgID == SiteOrganizationTypeP2 || orgID == SiteOrganizationTypeAutomattic;
+}
+
- (NSString *)authorString
{
if ([self.authorDisplayName length] > 0) {
@@ -187,6 +371,17 @@ - (NSString *)titleForDisplay
return title;
}
+- (NSArray *)tagsForDisplay
+{
+ if (self.tags.length <= 0) {
+ return @[];
+ }
+
+ NSArray *tags = [self.tags componentsSeparatedByString:@", "];
+
+ return [tags sortedArrayUsingSelector:@selector(localizedCompare:)];
+}
+
- (NSString *)authorForDisplay
{
return [self authorString];
@@ -285,6 +480,11 @@ - (NSString *)siteURLForDisplay
return self.blogURL;
}
+- (NSString *)siteHostNameForDisplay
+{
+ return self.blogURL.hostname;
+}
+
- (NSString *)crossPostOriginSiteURLForDisplay
{
return self.crossPostMeta.siteURL;
@@ -310,4 +510,15 @@ - (NSDictionary *)railcarDictionary
return nil;
}
+- (void) didSave {
+ [super didSave];
+
+ // A ReaderCard can have either a post, or a list of topics, but not both.
+ // Since this card has a post, we can confidently set `topics` to NULL.
+ if ([self respondsToSelector:@selector(card)] && self.card.count > 0) {
+ self.card.allObjects[0].topics = NULL;
+ [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
+ }
+}
+
@end
diff --git a/WordPress/Classes/Models/ReaderPostContentProvider.h b/WordPress/Classes/Models/ReaderPostContentProvider.h
index 5f6680181164..cfd8891374db 100644
--- a/WordPress/Classes/Models/ReaderPostContentProvider.h
+++ b/WordPress/Classes/Models/ReaderPostContentProvider.h
@@ -8,6 +8,7 @@ typedef NS_ENUM(NSUInteger, SourceAttributionStyle) {
};
@protocol ReaderPostContentProvider
+- (NSNumber *)siteID;
- (NSURL *)siteIconForDisplayOfSize:(NSInteger)size;
- (SourceAttributionStyle)sourceAttributionStyle;
- (NSString *)sourceAuthorNameForDisplay;
@@ -22,6 +23,7 @@ typedef NS_ENUM(NSUInteger, SourceAttributionStyle) {
- (BOOL)commentsOpen;
- (BOOL)isFollowing;
- (BOOL)isLikesEnabled;
+- (BOOL)isAtomic;
- (BOOL)isPrivate;
- (BOOL)isLiked;
- (BOOL)isExternal;
@@ -33,6 +35,7 @@ typedef NS_ENUM(NSUInteger, SourceAttributionStyle) {
- (NSNumber *)wordCount;
- (NSString *)siteURLForDisplay;
+- (NSString *)siteHostNameForDisplay;
- (NSString *)crossPostOriginSiteURLForDisplay;
- (BOOL)isCommentCrossPost;
diff --git a/WordPress/Classes/Models/ReaderSaveForLaterTopic.swift b/WordPress/Classes/Models/ReaderSaveForLaterTopic.swift
deleted file mode 100644
index 67b8f8b4b3e0..000000000000
--- a/WordPress/Classes/Models/ReaderSaveForLaterTopic.swift
+++ /dev/null
@@ -1,30 +0,0 @@
-/// Plese do not review this class. This is basically a mock at the moment. It models a mock topic, so that I can test that the topic gets rendered in the UI
-final class ReaderSaveForLaterTopic: ReaderAbstractTopic {
- init() {
- let managedObjectContext = ReaderSaveForLaterTopic.setUpInMemoryManagedObjectContext()
- let entity = NSEntityDescription.entity(forEntityName: "ReaderDefaultTopic", in: managedObjectContext)
- super.init(entity: entity!, insertInto: managedObjectContext)
- }
-
- override open class var TopicType: String {
- return "saveForLater"
- }
-
- /// TODO. This function will have to go away
- static func setUpInMemoryManagedObjectContext() -> NSManagedObjectContext {
- let managedObjectModel = NSManagedObjectModel.mergedModel(from: [Bundle.main])!
-
- let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel)
-
- do {
- try persistentStoreCoordinator.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil)
- } catch {
- print("Adding in-memory persistent store failed")
- }
-
- let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
- managedObjectContext.persistentStoreCoordinator = persistentStoreCoordinator
-
- return managedObjectContext
- }
-}
diff --git a/WordPress/Classes/Models/ReaderSiteInfoSubscriptions.swift b/WordPress/Classes/Models/ReaderSiteInfoSubscriptions.swift
index 13f2c30faf30..db9647e13975 100644
--- a/WordPress/Classes/Models/ReaderSiteInfoSubscriptions.swift
+++ b/WordPress/Classes/Models/ReaderSiteInfoSubscriptions.swift
@@ -5,6 +5,22 @@ import CoreData
@objc public class ReaderSiteInfoSubscriptionPost: NSManagedObject {
@NSManaged open var siteTopic: ReaderSiteTopic
@NSManaged open var sendPosts: Bool
+
+ class func createOrUpdate(from remoteSiteInfo: RemoteReaderSiteInfo, topic: ReaderSiteTopic, context: NSManagedObjectContext) -> ReaderSiteInfoSubscriptionPost? {
+ guard let postSubscription = remoteSiteInfo.postSubscription, postSubscription.wp_isValidObject() else {
+ return nil
+ }
+
+ var subscription = topic.postSubscription
+ if subscription?.wp_isValidObject() == false {
+ subscription = ReaderSiteInfoSubscriptionPost(context: context)
+ }
+
+ subscription?.siteTopic = topic
+ subscription?.sendPosts = postSubscription.sendPosts
+
+ return subscription
+ }
}
@@ -13,4 +29,22 @@ import CoreData
@NSManaged open var sendPosts: Bool
@NSManaged open var sendComments: Bool
@NSManaged open var postDeliveryFrequency: String
+
+ class func createOrUpdate(from remoteSiteInfo: RemoteReaderSiteInfo, topic: ReaderSiteTopic, context: NSManagedObjectContext) -> ReaderSiteInfoSubscriptionEmail? {
+ guard let emailSubscription = remoteSiteInfo.emailSubscription, emailSubscription.wp_isValidObject() else {
+ return nil
+ }
+
+ var subscription = topic.emailSubscription
+ if subscription?.wp_isValidObject() == false {
+ subscription = ReaderSiteInfoSubscriptionEmail(context: context)
+ }
+
+ subscription?.siteTopic = topic
+ subscription?.sendPosts = emailSubscription.sendPosts
+ subscription?.sendComments = emailSubscription.sendComments
+ subscription?.postDeliveryFrequency = emailSubscription.postDeliveryFrequency
+
+ return subscription
+ }
}
diff --git a/WordPress/Classes/Models/ReaderSiteTopic+Lookup.swift b/WordPress/Classes/Models/ReaderSiteTopic+Lookup.swift
new file mode 100644
index 000000000000..85cddf750c88
--- /dev/null
+++ b/WordPress/Classes/Models/ReaderSiteTopic+Lookup.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+extension ReaderSiteTopic {
+
+ /// Find a site topic by its site id
+ ///
+ /// - Parameter siteID: The site id of the topic
+ static func lookup(withSiteID siteID: NSNumber, in context: NSManagedObjectContext) throws -> ReaderSiteTopic? {
+ let request = NSFetchRequest(entityName: ReaderSiteTopic.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "siteID = %@", siteID)
+ request.fetchLimit = 1
+ return try context.fetch(request).first
+ }
+
+ /// Find a site topic by its site id
+ ///
+ /// - Parameter siteID: The site id of the topic
+ @objc(lookupWithSiteID:inContext:)
+ static func objc_lookup(withSiteID siteID: NSNumber, in context: NSManagedObjectContext) -> ReaderSiteTopic? {
+ try? lookup(withSiteID: siteID, in: context)
+ }
+
+ /// Find a site topic by its feed id
+ ///
+ /// - Parameter feedID: The feed id of the topic
+ static func lookup(withFeedID feedID: NSNumber, in context: NSManagedObjectContext) throws -> ReaderSiteTopic? {
+ let request = NSFetchRequest(entityName: ReaderSiteTopic.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "feedID = %@", feedID)
+ request.fetchLimit = 1
+ return try context.fetch(request).first
+ }
+
+ /// Find a site topic by its feed id
+ ///
+ /// - Parameter feedID: The feed id of the topic
+ @objc(lookupWithFeedID:inContext:)
+ static func objc_lookup(withFeedID feedID: NSNumber, in context: NSManagedObjectContext) -> ReaderSiteTopic? {
+ try? lookup(withFeedID: feedID, in: context)
+ }
+
+ /// Find a site topic by its feed URL
+ ///
+ /// - Parameter feedURL: The feed URL of the topic
+ static func lookup(withFeedURL feedURL: String, in context: NSManagedObjectContext) throws -> ReaderSiteTopic? {
+ let request = NSFetchRequest(entityName: ReaderSiteTopic.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "feedURL = %@", feedURL)
+ request.fetchLimit = 1
+ return try context.fetch(request).first
+ }
+
+ /// Find a site topic by its feed URL
+ ///
+ /// - Parameter feedURL: The feed URL of the topic
+ @objc(lookupWithFeedURL:inContext:)
+ static func objc_lookup(withFeedURL feedURL: String, in context: NSManagedObjectContext) -> ReaderSiteTopic? {
+ try? lookup(withFeedURL: feedURL, in: context)
+ }
+}
diff --git a/WordPress/Classes/Models/ReaderSiteTopic.swift b/WordPress/Classes/Models/ReaderSiteTopic.swift
index 9952f8d3a223..48f0cc32206f 100644
--- a/WordPress/Classes/Models/ReaderSiteTopic.swift
+++ b/WordPress/Classes/Models/ReaderSiteTopic.swift
@@ -11,12 +11,15 @@ import Foundation
@NSManaged open var isJetpack: Bool
@NSManaged open var isPrivate: Bool
@NSManaged open var isVisible: Bool
+ @NSManaged open var organizationID: Int
@NSManaged open var postCount: NSNumber
@NSManaged open var siteBlavatar: String
@NSManaged open var siteDescription: String
@NSManaged open var siteID: NSNumber
@NSManaged open var siteURL: String
@NSManaged open var subscriberCount: NSNumber
+ @NSManaged open var unseenCount: Int
+ @NSManaged open var cards: NSOrderedSet?
override open class var TopicType: String {
return "site"
@@ -28,6 +31,14 @@ import Foundation
}
}
+ var organizationType: SiteOrganizationType {
+ SiteOrganizationType(rawValue: organizationID) ?? .none
+ }
+
+ var isP2Type: Bool {
+ return organizationType == .p2 || organizationType == .automattic
+ }
+
@objc open var blogNameToDisplay: String {
return posts.first?.blogNameForDisplay() ?? title
}
@@ -35,4 +46,48 @@ import Foundation
@objc open var isSubscribedForPostNotifications: Bool {
return postSubscription?.sendPosts ?? false
}
+
+
+ /// Creates a new ReaderTagTopic object from a RemoteReaderInterest
+ convenience init(remoteInfo: RemoteReaderSiteInfo, context: NSManagedObjectContext) {
+ self.init(context: context)
+
+ feedID = remoteInfo.feedID ?? 0
+ feedURL = remoteInfo.feedURL ?? ""
+ following = remoteInfo.isFollowing
+ isJetpack = remoteInfo.isJetpack
+ isPrivate = remoteInfo.isPrivate
+ isVisible = remoteInfo.isVisible
+ organizationID = remoteInfo.organizationID?.intValue ?? 0
+ path = remoteInfo.postsEndpoint ?? remoteInfo.endpointPath ?? ""
+ postCount = remoteInfo.postCount ?? 0
+ showInMenu = false
+ siteBlavatar = remoteInfo.siteBlavatar ?? ""
+ siteDescription = remoteInfo.siteDescription ?? ""
+ siteID = remoteInfo.siteID ?? 0
+ siteURL = remoteInfo.siteURL ?? ""
+ subscriberCount = remoteInfo.subscriberCount ?? 0
+ title = remoteInfo.siteName ?? ""
+ type = Self.TopicType
+
+ postSubscription = ReaderSiteInfoSubscriptionPost.createOrUpdate(from: remoteInfo, topic: self, context: context)
+ emailSubscription = ReaderSiteInfoSubscriptionEmail.createOrUpdate(from: remoteInfo, topic: self, context: context)
+ }
+
+ class func createIfNeeded(from remoteInfo: RemoteReaderSiteInfo, context: NSManagedObjectContext) -> ReaderSiteTopic {
+ guard let path = remoteInfo.postsEndpoint ?? remoteInfo.endpointPath else {
+ return ReaderSiteTopic(remoteInfo: remoteInfo, context: context)
+ }
+
+ let fetchRequest = NSFetchRequest(entityName: ReaderAbstractTopic.classNameWithoutNamespaces())
+ fetchRequest.predicate = NSPredicate(format: "path = %@ OR path ENDSWITH %@", path, path)
+
+ let topics = try? context.fetch(fetchRequest) as? [ReaderSiteTopic]
+
+ guard let topic = topics?.first else {
+ return ReaderSiteTopic(remoteInfo: remoteInfo, context: context)
+ }
+
+ return topic
+ }
}
diff --git a/WordPress/Classes/Models/ReaderTagTopic+Lookup.swift b/WordPress/Classes/Models/ReaderTagTopic+Lookup.swift
new file mode 100644
index 000000000000..07229302b0d0
--- /dev/null
+++ b/WordPress/Classes/Models/ReaderTagTopic+Lookup.swift
@@ -0,0 +1,51 @@
+import Foundation
+
+extension ReaderTagTopic {
+
+ /// Find an existing topic with the specified slug.
+ ///
+ /// - Parameter slug: The slug of the topic to find in core data.
+ /// - Returns: A matching `ReaderTagTopic` instance or nil.
+ static func lookup(withSlug slug: String, in context: NSManagedObjectContext) throws -> ReaderTagTopic? {
+ let request = NSFetchRequest(entityName: ReaderTagTopic.classNameWithoutNamespaces())
+ request.fetchLimit = 1
+ request.predicate = NSPredicate(format: "slug = %@", slug)
+ request.sortDescriptors = [
+ NSSortDescriptor(key: "title", ascending: true)
+ ]
+ return try context.fetch(request).first
+ }
+
+ /// Find an existing topic with the specified slug.
+ ///
+ /// - Parameter slug: The slug of the topic to find in core data.
+ /// - Returns: A matching `ReaderTagTopic` instance or nil.
+ @objc(lookupWithSlug:inContext:)
+ static func objc_lookup(withSlug slug: String, in context: NSManagedObjectContext) -> ReaderTagTopic? {
+ try? lookup(withSlug: slug, in: context)
+ }
+
+ /// Find an existing topic with the specified topicID.
+ ///
+ /// - Parameter tagID: The tag id of the topic to find in core data.
+ /// - Returns: A matching `ReaderTagTopic` instance or nil.
+ static func lookup(withTagID tagID: NSNumber, in context: NSManagedObjectContext) throws -> ReaderTagTopic? {
+ let request = NSFetchRequest(entityName: ReaderTagTopic.classNameWithoutNamespaces())
+ request.fetchLimit = 1
+ request.predicate = NSPredicate(format: "tagID = %@", tagID)
+ request.sortDescriptors = [
+ NSSortDescriptor(key: "title", ascending: true)
+ ]
+ return try context.fetch(request).first
+ }
+
+ /// Find an existing topic with the specified topicID.
+ ///
+ /// - Parameter tagID: The tag id of the topic to find in core data.
+ /// - Returns: A matching `ReaderTagTopic` instance or nil.
+ @objc(lookupWithTagID:inContext:)
+ static func objc_lookup(withTagID tagID: NSNumber, in context: NSManagedObjectContext) -> ReaderTagTopic? {
+ try? lookup(withTagID: tagID, in: context)
+ }
+
+}
diff --git a/WordPress/Classes/Models/ReaderTagTopic.swift b/WordPress/Classes/Models/ReaderTagTopic.swift
index ec6fa6ef45de..110b5a96f5c2 100644
--- a/WordPress/Classes/Models/ReaderTagTopic.swift
+++ b/WordPress/Classes/Models/ReaderTagTopic.swift
@@ -4,8 +4,44 @@ import Foundation
@NSManaged open var isRecommended: Bool
@NSManaged open var slug: String
@NSManaged open var tagID: NSNumber
+ @NSManaged open var cards: NSOrderedSet?
override open class var TopicType: String {
return "tag"
}
+
+ // MARK: - Logged Out Helpers
+
+ /// The tagID used if an interest was added locally and not sync'd with the server
+ class var loggedOutTagID: NSNumber {
+ return NSNotFound as NSNumber
+ }
+
+ /// Creates a new ReaderTagTopic object from a RemoteReaderInterest
+ convenience init(remoteInterest: RemoteReaderInterest, context: NSManagedObjectContext, isFollowing: Bool = false) {
+ self.init(context: context)
+
+ title = remoteInterest.title
+ slug = remoteInterest.slug
+ tagID = Self.loggedOutTagID
+ type = Self.TopicType
+ following = isFollowing
+ showInMenu = true
+ }
+
+ /// Returns an existing ReaderTagTopic or creates a new one based on remote interest
+ /// If an existing topic is returned, the title will be updated with the remote interest
+ class func createOrUpdateIfNeeded(from remoteInterest: RemoteReaderInterest, context: NSManagedObjectContext) -> ReaderTagTopic {
+ let fetchRequest = NSFetchRequest(entityName: self.classNameWithoutNamespaces())
+ fetchRequest.predicate = NSPredicate(format: "slug = %@", remoteInterest.slug)
+ let topics = try? context.fetch(fetchRequest) as? [ReaderTagTopic]
+
+ guard let topic = topics?.first else {
+ return ReaderTagTopic(remoteInterest: remoteInterest, context: context)
+ }
+
+ topic.title = remoteInterest.title
+
+ return topic
+ }
}
diff --git a/WordPress/Classes/Models/ReaderTeamTopic.swift b/WordPress/Classes/Models/ReaderTeamTopic.swift
index a28b0e039ada..b821f85c129f 100644
--- a/WordPress/Classes/Models/ReaderTeamTopic.swift
+++ b/WordPress/Classes/Models/ReaderTeamTopic.swift
@@ -2,22 +2,20 @@ import Foundation
@objc open class ReaderTeamTopic: ReaderAbstractTopic {
@NSManaged open var slug: String
+ @NSManaged open var organizationID: Int
override open class var TopicType: String {
- return "team"
+ return "organization"
}
- @objc open var icon: UIImage? {
- guard bundledTeamIcons.contains(slug) else {
- return nil
- }
-
- return UIImage(named: slug)
+ var shownTrackEvent: WPAnalyticsEvent {
+ return slug == ReaderTeamTopic.a8cSlug ? .readerA8CShown : .readerP2Shown
}
- fileprivate let bundledTeamIcons: [String] = [
- ReaderTeamTopic.a8cTeamSlug
- ]
+ var organizationType: SiteOrganizationType {
+ return SiteOrganizationType(rawValue: organizationID) ?? .none
+ }
- static let a8cTeamSlug = "a8c"
+ static let a8cSlug = "a8c"
+ static let p2Slug = "p2"
}
diff --git a/WordPress/Classes/Models/Role.swift b/WordPress/Classes/Models/Role.swift
index cdb328882dad..9bd0615bea19 100644
--- a/WordPress/Classes/Models/Role.swift
+++ b/WordPress/Classes/Models/Role.swift
@@ -12,6 +12,14 @@ extension Role {
func toUnmanaged() -> RemoteRole {
return RemoteRole(slug: slug, name: name)
}
+
+ static func lookup(withBlogID blogID: NSManagedObjectID, slug: String, in context: NSManagedObjectContext) throws -> Role? {
+ guard let blog = try context.existingObject(with: blogID) as? Blog else {
+ return nil
+ }
+ let predicate = NSPredicate(format: "slug = %@ AND blog = %@", slug, blog)
+ return context.firstObject(ofType: Role.self, matching: predicate)
+ }
}
extension Role {
diff --git a/WordPress/Classes/Models/SharingButton+Lookup.swift b/WordPress/Classes/Models/SharingButton+Lookup.swift
new file mode 100644
index 000000000000..df75928bd2b0
--- /dev/null
+++ b/WordPress/Classes/Models/SharingButton+Lookup.swift
@@ -0,0 +1,29 @@
+extension SharingButton {
+
+ /// Returns an array of all cached `SharingButtons` objects.
+ ///
+ /// - Returns: An array of `SharingButton`s. The array is empty if no objects are cached.
+ ///
+ @objc(allSharingButtonsForBlog:inContext:error:)
+ static func allSharingButtons(for blog: Blog, in context: NSManagedObjectContext) throws -> [SharingButton] {
+ let request = NSFetchRequest(entityName: SharingButton.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "blog = %@", blog)
+ request.sortDescriptors = [NSSortDescriptor(key: "order", ascending: true)]
+ return try context.fetch(request)
+ }
+
+ /// Finds a cached `SharingButton` by its `buttonID` for the specified `Blog`
+ ///
+ /// - Parameters:
+ /// - buttonID: The button ID of the `SharingButton`.
+ /// - blog: The blog that owns the sharing button.
+ ///
+ /// - Returns: The requested `SharingButton` or nil.
+ ///
+ static func lookupSharingButton(byID buttonID: String, for blog: Blog, in context: NSManagedObjectContext) throws -> SharingButton? {
+ let request = NSFetchRequest(entityName: SharingButton.classNameWithoutNamespaces())
+ request.predicate = NSPredicate(format: "buttonID = %@ AND blog = %@", buttonID, blog)
+ return try context.fetch(request).first
+ }
+
+}
diff --git a/WordPress/Classes/Models/SiteInformation.swift b/WordPress/Classes/Models/SiteInformation.swift
index 0ddd2000dee1..d4f00ab9050f 100644
--- a/WordPress/Classes/Models/SiteInformation.swift
+++ b/WordPress/Classes/Models/SiteInformation.swift
@@ -1,6 +1,15 @@
struct SiteInformation {
let title: String
let tagLine: String?
+
+ /// if title is nil, then the corresponding SiteInformation value is nil
+ init?(title: String?, tagLine: String?) {
+ guard let title = title else {
+ return nil
+ }
+ self.title = title
+ self.tagLine = tagLine
+ }
}
extension SiteInformation: Equatable {
diff --git a/WordPress/Classes/Models/SiteSuggestion+CoreDataClass.swift b/WordPress/Classes/Models/SiteSuggestion+CoreDataClass.swift
new file mode 100644
index 000000000000..fd7487bb82e1
--- /dev/null
+++ b/WordPress/Classes/Models/SiteSuggestion+CoreDataClass.swift
@@ -0,0 +1,34 @@
+import Foundation
+import CoreData
+
+extension CodingUserInfoKey {
+ static let managedObjectContext = CodingUserInfoKey(rawValue: "managedObjectContext")!
+}
+
+enum DecoderError: Error {
+ case missingManagedObjectContext
+}
+
+@objc(SiteSuggestion)
+public class SiteSuggestion: NSManagedObject, Decodable {
+ enum CodingKeys: String, CodingKey {
+ case title = "title"
+ case siteURL = "siteurl"
+ case subdomain = "subdomain"
+ case blavatarURL = "blavatar"
+ }
+
+ required convenience public init(from decoder: Decoder) throws {
+ guard let managedObjectContext = decoder.userInfo[CodingUserInfoKey.managedObjectContext] as? NSManagedObjectContext else {
+ throw DecoderError.missingManagedObjectContext
+ }
+
+ self.init(context: managedObjectContext)
+
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.title = try container.decode(String.self, forKey: .title)
+ self.siteURL = try? container.decode(URL.self, forKey: .siteURL)
+ self.subdomain = try container.decode(String.self, forKey: .subdomain)
+ self.blavatarURL = try? container.decode(URL.self, forKey: .blavatarURL)
+ }
+}
diff --git a/WordPress/Classes/Models/SiteSuggestion+CoreDataProperties.swift b/WordPress/Classes/Models/SiteSuggestion+CoreDataProperties.swift
new file mode 100644
index 000000000000..5c9245936aba
--- /dev/null
+++ b/WordPress/Classes/Models/SiteSuggestion+CoreDataProperties.swift
@@ -0,0 +1,17 @@
+import Foundation
+import CoreData
+
+
+extension SiteSuggestion {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "SiteSuggestion")
+ }
+
+ @NSManaged public var title: String?
+ @NSManaged public var siteURL: URL?
+ @NSManaged public var subdomain: String?
+ @NSManaged public var blavatarURL: URL?
+ @NSManaged public var blog: Blog?
+
+}
diff --git a/WordPress/Classes/Models/Suggestion.h b/WordPress/Classes/Models/Suggestion.h
deleted file mode 100644
index 925cbabc99bd..000000000000
--- a/WordPress/Classes/Models/Suggestion.h
+++ /dev/null
@@ -1,16 +0,0 @@
-#import
-
-typedef void(^SuggestionAvatarFetchSuccessBlock)(UIImage* image);
-
-@interface Suggestion : NSObject
-
-@property (nonatomic, strong) NSString *userLogin;
-@property (nonatomic, strong) NSString *displayName;
-@property (nonatomic, strong) NSURL *imageURL;
-
-+ (instancetype)suggestionFromDictionary:(NSDictionary *)dictionary;
-
-- (UIImage *)cachedAvatarWithSize:(CGSize)size;
-- (void)fetchAvatarWithSize:(CGSize)size success:(SuggestionAvatarFetchSuccessBlock)success;
-
-@end
diff --git a/WordPress/Classes/Models/Suggestion.m b/WordPress/Classes/Models/Suggestion.m
deleted file mode 100644
index be9be5e3352c..000000000000
--- a/WordPress/Classes/Models/Suggestion.m
+++ /dev/null
@@ -1,46 +0,0 @@
-#import "Suggestion.h"
-#import "WPAvatarSource.h"
-
-@implementation Suggestion
-
-+ (instancetype)suggestionFromDictionary:(NSDictionary *)dictionary {
- Suggestion *suggestion = [Suggestion new];
-
- suggestion.userLogin = [dictionary stringForKey:@"user_login"];
- suggestion.displayName = [dictionary stringForKey:@"display_name"];
- suggestion.imageURL = [NSURL URLWithString:[dictionary stringForKey:@"image_URL"]];
-
- return suggestion;
-}
-
-- (UIImage *)cachedAvatarWithSize:(CGSize)size
-{
- NSString *hash;
- WPAvatarSourceType type = [self avatarSourceTypeWithHash:&hash];
- if (!hash) {
- return nil;
- }
- return [[WPAvatarSource sharedSource] cachedImageForAvatarHash:hash ofType:type withSize:size];
-}
-
-- (void)fetchAvatarWithSize:(CGSize)size success:(void (^)(UIImage *image))success
-{
- NSString *hash;
- WPAvatarSourceType type = [self avatarSourceTypeWithHash:&hash];
-
- if (hash) {
- [[WPAvatarSource sharedSource] fetchImageForAvatarHash:hash ofType:type withSize:size success:success];
- } else if (success) {
- success(nil);
- }
-}
-
-- (WPAvatarSourceType)avatarSourceTypeWithHash:(NSString **)hash
-{
- if (self.imageURL) {
- return [[WPAvatarSource sharedSource] parseURL:self.imageURL forAvatarHash:hash];
- }
- return WPAvatarSourceTypeUnknown;
-}
-
-@end
diff --git a/WordPress/Classes/Models/Suggestion.swift b/WordPress/Classes/Models/Suggestion.swift
new file mode 100644
index 000000000000..a598adb40dcd
--- /dev/null
+++ b/WordPress/Classes/Models/Suggestion.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+@objcMembers public class Suggestion: NSObject {
+ let userLogin: String?
+ let displayName: String?
+ let imageURL: URL?
+
+ init?(dictionary: [String: Any]) {
+
+ let userLogin = dictionary["user_login"] as? String
+ let displayName = dictionary["display_name"] as? String
+
+ // A user suggestion is only valid when at least one of these is present.
+ guard userLogin != nil || displayName != nil else {
+ return nil
+ }
+
+ self.userLogin = userLogin
+ self.displayName = displayName
+
+ if let imageURLString = dictionary["image_URL"] as? String {
+ imageURL = URL(string: imageURLString)
+ } else {
+ imageURL = nil
+ }
+ }
+}
diff --git a/WordPress/Classes/Models/Theme.m b/WordPress/Classes/Models/Theme.m
index 61c45381a421..d8e3586fed76 100644
--- a/WordPress/Classes/Models/Theme.m
+++ b/WordPress/Classes/Models/Theme.m
@@ -1,6 +1,6 @@
#import "Theme.h"
#import "Blog.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "WPAccount.h"
#import "AccountService.h"
#import "WordPress-Swift.h"
diff --git a/WordPress/Classes/Models/UserSettings.swift b/WordPress/Classes/Models/UserSettings.swift
new file mode 100644
index 000000000000..88638663d12d
--- /dev/null
+++ b/WordPress/Classes/Models/UserSettings.swift
@@ -0,0 +1,90 @@
+import Foundation
+
+struct UserSettings {
+ /// Stores all `UserSettings` keys.
+ ///
+ /// The additional level of indirection allows these keys to be retrieved from tests.
+ ///
+ /// **IMPORTANT NOTE:**
+ ///
+ /// Any change to these keys is a breaking change without some kind of migration.
+ /// It's probably best never to change them.
+ enum Keys: String, CaseIterable {
+ case crashLoggingOptOutKey = "crashlytics_opt_out"
+ case forceCrashLoggingKey = "force-crash-logging"
+ case defaultDotComUUIDKey = "AccountDefaultDotcomUUID"
+ }
+
+ @UserDefault(Keys.crashLoggingOptOutKey.rawValue, defaultValue: false)
+ static var userHasOptedOutOfCrashLogging: Bool
+
+ @UserDefault(Keys.forceCrashLoggingKey.rawValue, defaultValue: false)
+ static var userHasForcedCrashLoggingEnabled: Bool
+
+ @NullableUserDefault(Keys.defaultDotComUUIDKey.rawValue)
+ static var defaultDotComUUID: String?
+
+ /// Reset all UserSettings back to their defaults
+ static func reset() {
+ UserSettings.Keys.allCases.forEach { UserPersistentStoreFactory.instance().removeObject(forKey: $0.rawValue) }
+ }
+}
+
+/// Objective-C Wrapper for UserSettings
+@objc(UserSettings)
+class ObjcCUserSettings: NSObject {
+ @objc
+ static var defaultDotComUUID: String? {
+ get { UserSettings.defaultDotComUUID }
+ set { UserSettings.defaultDotComUUID = newValue }
+ }
+
+ @objc
+ static func reset() {
+ UserSettings.reset()
+ }
+}
+
+/// A property wrapper for UserDefaults access
+@propertyWrapper
+struct UserDefault {
+ let key: String
+ let defaultValue: T
+
+ init(_ key: String, defaultValue: T) {
+ self.key = key
+ self.defaultValue = defaultValue
+ }
+
+ var wrappedValue: T {
+ get {
+ return UserPersistentStoreFactory.instance().object(forKey: key) as? T ?? defaultValue
+ }
+ set {
+ UserPersistentStoreFactory.instance().set(newValue, forKey: key)
+ }
+ }
+}
+
+/// A property wrapper for optional UserDefaults that return `nil` by default
+@propertyWrapper
+struct NullableUserDefault {
+ let key: String
+
+ init(_ key: String) {
+ self.key = key
+ }
+
+ var wrappedValue: T? {
+ get {
+ return UserPersistentStoreFactory.instance().object(forKey: key) as? T
+ }
+ set {
+ if let newValue = newValue {
+ UserPersistentStoreFactory.instance().set(newValue, forKey: key)
+ } else {
+ UserPersistentStoreFactory.instance().removeObject(forKey: key)
+ }
+ }
+ }
+}
diff --git a/WordPress/Classes/Models/UserSuggestion+Comparable.swift b/WordPress/Classes/Models/UserSuggestion+Comparable.swift
new file mode 100644
index 000000000000..7d5188a68801
--- /dev/null
+++ b/WordPress/Classes/Models/UserSuggestion+Comparable.swift
@@ -0,0 +1,11 @@
+extension UserSuggestion: Comparable {
+ public static func < (lhs: UserSuggestion, rhs: UserSuggestion) -> Bool {
+ if let leftDisplayName = lhs.displayName, let rightDisplayName = rhs.displayName {
+ return leftDisplayName.localizedCaseInsensitiveCompare(rightDisplayName) == .orderedAscending
+ } else if let leftUsername = lhs.username, let rightUsername = rhs.username {
+ return leftUsername < rightUsername
+ }
+
+ return false
+ }
+}
diff --git a/WordPress/Classes/Models/UserSuggestion+CoreDataClass.swift b/WordPress/Classes/Models/UserSuggestion+CoreDataClass.swift
new file mode 100644
index 000000000000..3baceacd9cea
--- /dev/null
+++ b/WordPress/Classes/Models/UserSuggestion+CoreDataClass.swift
@@ -0,0 +1,32 @@
+import Foundation
+import CoreData
+
+@objc(UserSuggestion)
+public class UserSuggestion: NSManagedObject {
+
+ convenience init?(dictionary: [String: Any], context: NSManagedObjectContext) {
+ let userLoginValue = dictionary["user_login"] as? String
+ let displayNameValue = dictionary["display_name"] as? String
+
+ // A user suggestion is only valid when it has an ID and at least user_login or display_name is present.
+ guard let id = dictionary["ID"] as? UInt, userLoginValue != nil || displayNameValue != nil else {
+ return nil
+ }
+
+ guard let entityDescription = NSEntityDescription.entity(forEntityName: "UserSuggestion", in: context) else {
+ return nil
+ }
+ self.init(entity: entityDescription, insertInto: context)
+
+ self.userID = NSNumber(value: id)
+ self.username = userLoginValue
+ self.displayName = displayNameValue
+
+ if let imageURLString = dictionary["image_URL"] as? String {
+ imageURL = URL(string: imageURLString)
+ } else {
+ imageURL = nil
+ }
+ }
+
+}
diff --git a/WordPress/Classes/Models/UserSuggestion+CoreDataProperties.swift b/WordPress/Classes/Models/UserSuggestion+CoreDataProperties.swift
new file mode 100644
index 000000000000..005c62bd8482
--- /dev/null
+++ b/WordPress/Classes/Models/UserSuggestion+CoreDataProperties.swift
@@ -0,0 +1,16 @@
+import Foundation
+import CoreData
+
+
+extension UserSuggestion {
+
+ @nonobjc public class func fetchRequest() -> NSFetchRequest {
+ return NSFetchRequest(entityName: "UserSuggestion")
+ }
+
+ @NSManaged public var userID: NSNumber?
+ @NSManaged public var displayName: String?
+ @NSManaged public var imageURL: URL?
+ @NSManaged public var username: String?
+ @NSManaged public var blog: Blog?
+}
diff --git a/WordPress/Classes/Models/ValueTransformers.swift b/WordPress/Classes/Models/ValueTransformers.swift
new file mode 100644
index 000000000000..af2e60fcc8eb
--- /dev/null
+++ b/WordPress/Classes/Models/ValueTransformers.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+extension ValueTransformer {
+ @objc
+ static func registerCustomTransformers() {
+ CoordinateValueTransformer.register()
+ NSErrorValueTransformer.register()
+ SetValueTransformer.register()
+ }
+}
+
+@objc
+final class CoordinateValueTransformer: NSSecureUnarchiveFromDataTransformer {
+
+ static let name = NSValueTransformerName(rawValue: String(describing: CoordinateValueTransformer.self))
+
+ override static var allowedTopLevelClasses: [AnyClass] {
+ return [Coordinate.self]
+ }
+
+ @objc
+ public static func register() {
+ let transformer = CoordinateValueTransformer()
+ ValueTransformer.setValueTransformer(transformer, forName: name)
+ }
+}
+
+@objc
+final class NSErrorValueTransformer: NSSecureUnarchiveFromDataTransformer {
+
+ static let name = NSValueTransformerName(rawValue: String(describing: NSErrorValueTransformer.self))
+
+ override static var allowedTopLevelClasses: [AnyClass] {
+ return [NSError.self]
+ }
+
+ @objc
+ public static func register() {
+ let transformer = NSErrorValueTransformer()
+ ValueTransformer.setValueTransformer(transformer, forName: name)
+ }
+}
+
+@objc
+final class SetValueTransformer: NSSecureUnarchiveFromDataTransformer {
+
+ static let name = NSValueTransformerName(rawValue: String(describing: SetValueTransformer.self))
+
+ override static var allowedTopLevelClasses: [AnyClass] {
+ return [NSSet.self]
+ }
+
+ @objc
+ public static func register() {
+ let transformer = SetValueTransformer()
+ ValueTransformer.setValueTransformer(transformer, forName: name)
+ }
+}
diff --git a/WordPress/Classes/Models/WPAccount+AccountSettings.swift b/WordPress/Classes/Models/WPAccount+AccountSettings.swift
index 94334953dd75..2ee84d010466 100644
--- a/WordPress/Classes/Models/WPAccount+AccountSettings.swift
+++ b/WordPress/Classes/Models/WPAccount+AccountSettings.swift
@@ -12,8 +12,7 @@ extension WPAccount {
case .displayName(let value):
self.displayName = value
case .primarySite(let value):
- let service = BlogService(managedObjectContext: managedObjectContext!)
- defaultBlog = service.blog(byBlogId: NSNumber(value: value))
+ defaultBlog = try? Blog.lookup(withID: value, in: managedObjectContext!)
default:
break
}
diff --git a/WordPress/Classes/Models/WPAccount+DeduplicateBlogs.swift b/WordPress/Classes/Models/WPAccount+DeduplicateBlogs.swift
new file mode 100644
index 000000000000..646418f635a0
--- /dev/null
+++ b/WordPress/Classes/Models/WPAccount+DeduplicateBlogs.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+extension WPAccount {
+ /// Removes any duplicate blogs in the given account
+ ///
+ /// We consider a blog to be a duplicate of another if they have the same dotComID.
+ /// For each group of duplicate blogs, this will delete all but one, giving preference to
+ /// blogs that have local drafts.
+ ///
+ /// If there's more than one blog in each group with local drafts, those will be reassigned
+ /// to the remaining blog.
+ ///
+ @objc(deduplicateBlogs)
+ func deduplicateBlogs() {
+ let context = managedObjectContext!
+ // Group all the account blogs by ID so it's easier to find duplicates
+ let blogsById = Dictionary(grouping: blogs, by: { $0.dotComID?.intValue ?? 0 })
+ // For any group with more than one blog, remove duplicates
+ for (blogID, group) in blogsById where group.count > 1 {
+ assert(blogID > 0, "There should not be a Blog without ID if it has an account")
+ guard blogID > 0 else {
+ DDLogError("Found one or more WordPress.com blogs without ID, skipping de-duplication")
+ continue
+ }
+ DDLogWarn("Found \(group.count - 1) duplicates for blog with ID \(blogID)")
+ deduplicate(group: group, in: context)
+ }
+ }
+
+ private func deduplicate(group: [Blog], in context: NSManagedObjectContext) {
+ // If there's a blog with local drafts, we'll preserve that one, otherwise we pick up the first
+ // since we don't really care which blog to pick
+ let candidateIndex = group.firstIndex(where: { !localDrafts(for: $0).isEmpty }) ?? 0
+ let candidate = group[candidateIndex]
+
+ // We look through every other blog
+ for (index, blog) in group.enumerated() where index != candidateIndex {
+ // If there are other blogs with local drafts, we reassing them to the blog that
+ // is not going to be deleted
+ for draft in localDrafts(for: blog) {
+ DDLogInfo("Migrating local draft \(draft.postTitle ?? "") to de-duplicated blog")
+ draft.blog = candidate
+ }
+ // Once the drafts are moved (if any), we can safely delete the duplicate
+ DDLogInfo("Deleting duplicate blog \(blog.logDescription())")
+ context.delete(blog)
+ }
+ }
+
+ private func localDrafts(for blog: Blog) -> [AbstractPost] {
+ // The original predicate from PostService.countPostsWithoutRemote() was:
+ // "postID = NULL OR postID <= 0"
+ // Swift optionals make things a bit more verbose, but this should be equivalent
+ return blog.posts?.filter({ (post) -> Bool in
+ if let postID = post.postID?.intValue,
+ postID > 0 {
+ return false
+ }
+ return true
+ }) ?? []
+ }
+}
diff --git a/WordPress/Classes/Models/WPAccount+Lookup.swift b/WordPress/Classes/Models/WPAccount+Lookup.swift
new file mode 100644
index 000000000000..1ba1629f8e73
--- /dev/null
+++ b/WordPress/Classes/Models/WPAccount+Lookup.swift
@@ -0,0 +1,164 @@
+import CoreData
+
+public extension WPAccount {
+
+ // MARK: - Relationship Lookups
+
+ /// Is this `WPAccount` object the default WordPress.com account?
+ ///
+ @objc
+ var isDefaultWordPressComAccount: Bool {
+ guard let uuid = UserSettings.defaultDotComUUID else {
+ return false
+ }
+
+ return self.uuid == uuid
+ }
+
+ /// Does this `WPAccount` object have any associated blogs?
+ ///
+ @objc
+ var hasBlogs: Bool {
+ return !blogs.isEmpty
+ }
+
+ // MARK: - Object Lookups
+
+ /// Returns the default WordPress.com account, if one exists
+ ///
+ /// The default WordPress.com account is the one used for Reader and Notifications.
+ ///
+ static func lookupDefaultWordPressComAccount(in context: NSManagedObjectContext) throws -> WPAccount? {
+ guard let uuid = UserSettings.defaultDotComUUID, !uuid.isEmpty else {
+ // No account, or no default account set. Clear the defaults key.
+ UserSettings.defaultDotComUUID = nil
+ return nil
+ }
+
+ return try lookup(withUUIDString: uuid, in: context)
+ }
+
+ /// Lookup a WPAccount by its local uuid
+ ///
+ /// - Parameters:
+ /// - uuidString: The UUID (in string form) associated with the account
+ /// - context: An NSManagedObjectContext containing the `WPAccount` object with the given `uuidString`.
+ /// - Returns: The `WPAccount` object associated with the given `uuidString`, if it exists.
+ ///
+ static func lookup(withUUIDString uuidString: String, in context: NSManagedObjectContext) throws -> WPAccount? {
+ let fetchRequest = NSFetchRequest(entityName: WPAccount.entityName())
+ fetchRequest.predicate = NSPredicate(format: "uuid = %@", uuidString)
+
+ guard let defaultAccount = try context.fetch(fetchRequest).first else {
+ return nil
+ }
+
+ /// This was brought over from the `AccountService`, but can (and probably should) be moved to an accessor for the property
+ if let displayName = defaultAccount.displayName {
+ defaultAccount.displayName = displayName.stringByDecodingXMLCharacters()
+ }
+
+ return defaultAccount
+ }
+
+ /// Lookup a WPAccount with the specified username, if it exists
+ ///
+ /// - Parameters:
+ /// - username: The username associated with the account
+ /// - context: An NSManagedObjectContext containing the `WPAccount` object with the given `username`.
+ /// - Returns: The `WPAccount` object associated with the given `username`, if it exists.
+ ///
+ static func lookup(withUsername username: String, in context: NSManagedObjectContext) throws -> WPAccount? {
+ let fetchRequest = NSFetchRequest(entityName: WPAccount.entityName())
+ fetchRequest.predicate = NSPredicate(format: "username = [c] %@ || email = [c] %@", username, username)
+
+ guard let account = try context.fetch(fetchRequest).first else {
+ return nil
+ }
+
+ return account
+ }
+
+ /// Lookup a WPAccount with the specified userID, if it exists
+ ///
+ /// - Parameters:
+ /// - userID: The userID associated with the account
+ /// - context: An NSManagedObjectContext containing the `WPAccount` object with the given `userID`.
+ /// - Returns: The `WPAccount` object associated with the given `userID`, if it exists.
+ ///
+ static func lookup(withUserID userID: Int64, in context: NSManagedObjectContext) throws -> WPAccount? {
+ let fetchRequest = NSFetchRequest(entityName: WPAccount.entityName())
+ fetchRequest.predicate = NSPredicate(format: "userID = %ld", userID)
+
+ guard let account = try context.fetch(fetchRequest).first else {
+ return nil
+ }
+
+ return account
+ }
+
+ /// Lookup the total number of `WPAccount` objects in the given `context`.
+ ///
+ /// If none exist, this method returns `0`.
+ ///
+ /// - Parameters:
+ /// - context: An NSManagedObjectContext that may or may not contain `WPAccount` objects.
+ /// - Returns: The number of `WPAccount` objects in the given `context`.
+ ///
+ static func lookupNumberOfAccounts(in context: NSManagedObjectContext) throws -> Int {
+ let fetchRequest = NSFetchRequest(entityName: WPAccount.entityName())
+ fetchRequest.includesSubentities = false
+ return try context.count(for: fetchRequest)
+ }
+
+ /// Lookup all the `WPAccount` objects in the given `context`.
+ ///
+ /// If none exist, this method returns empty array.
+ ///
+ /// - Parameters:
+ /// - context: An NSManagedObjectContext that may or may not contain `WPAccount` objects.
+ /// - Returns: All `WPAccount` objects in the given `context`.
+ ///
+ static func lookupAllAccounts(in context: NSManagedObjectContext) throws -> [WPAccount] {
+ let fetchRequest = NSFetchRequest(entityName: WPAccount.entityName())
+ return try context.fetch(fetchRequest)
+ }
+
+ // MARK: - Objective-C Compatibility Wrappers
+
+ /// An Objective-C wrapper around the `lookupDefaultWordPressComAccount` method.
+ ///
+ /// Prefer using `lookupDefaultWordPressComAccount` directly
+ @available(swift, obsoleted: 1.0)
+ @objc(lookupDefaultWordPressComAccountInContext:)
+ static func objc_lookupDefaultWordPressComAccount(in context: NSManagedObjectContext) -> WPAccount? {
+ return try? lookupDefaultWordPressComAccount(in: context)
+ }
+
+ /// An Objective-C wrapper around the `lookupDefaultWordPressComAccount` method.
+ ///
+ /// Prefer using `lookupDefaultWordPressComAccount` directly
+ @available(swift, obsoleted: 1.0)
+ @objc(lookupNumberOfAccountsInContext:)
+ static func objc_lookupNumberOfAccounts(in context: NSManagedObjectContext) -> Int {
+ return (try? lookupNumberOfAccounts(in: context)) ?? 0
+ }
+
+ /// An Objective-C wrapper around the `lookupAllAccounts(in:)` method.
+ ///
+ /// Prefer using `lookupAllAccounts(in:)` directly
+ @available(swift, obsoleted: 1.0)
+ @objc(lookupAllAccountsInContext:)
+ static func objc_lookupAllAccounts(in context: NSManagedObjectContext) -> [WPAccount] {
+ return (try? lookupAllAccounts(in: context)) ?? []
+ }
+
+ /// An Objective-C wrapper around the `lookup(withUsername:context:)` method.
+ ///
+ /// Prefer using `lookup(withUsername:context:)` directly
+ @available(swift, obsoleted: 1.0)
+ @objc(lookupWithUsername:context:)
+ static func objc_lookupWithUsername(username: String, context: NSManagedObjectContext) -> WPAccount? {
+ return try? lookup(withUsername: username, in: context)
+ }
+}
diff --git a/WordPress/Classes/Models/WPAccount.h b/WordPress/Classes/Models/WPAccount.h
index 110d41c98b12..a1ba030db6e4 100644
--- a/WordPress/Classes/Models/WPAccount.h
+++ b/WordPress/Classes/Models/WPAccount.h
@@ -48,5 +48,7 @@
- (void)removeBlogsObject:(Blog *)value;
- (void)addBlogs:(NSSet *)values;
- (void)removeBlogs:(NSSet *)values;
++ (NSString *)tokenForUsername:(NSString *)username;
+- (BOOL)hasAtomicSite;
@end
diff --git a/WordPress/Classes/Models/WPAccount.m b/WordPress/Classes/Models/WPAccount.m
index a677248592d5..b8a6fe709c3d 100644
--- a/WordPress/Classes/Models/WPAccount.m
+++ b/WordPress/Classes/Models/WPAccount.m
@@ -1,9 +1,6 @@
#import "WPAccount.h"
-#import "SFHFKeychainUtils.h"
#import "WordPress-Swift.h"
-static NSString * const WordPressComOAuthKeychainServiceName = @"public-api.wordpress.com";
-
@interface WPAccount ()
@property (nonatomic, strong, readwrite) WordPressComRestApi *wordPressComRestApi;
@@ -57,19 +54,19 @@ + (NSString *)entityName
- (void)setUsername:(NSString *)username
{
NSString *previousUsername = self.username;
-
+
BOOL usernameChanged = ![previousUsername isEqualToString:username];
NSString *authToken = nil;
-
+
if (usernameChanged) {
authToken = self.authToken;
self.authToken = nil;
}
-
+
[self willChangeValueForKey:@"username"];
[self setPrimitiveValue:username forKey:@"username"];
[self didChangeValueForKey:@"username"];
-
+
if (usernameChanged) {
self.authToken = authToken;
}
@@ -77,14 +74,7 @@ - (void)setUsername:(NSString *)username
- (NSString *)authToken
{
- NSError *error = nil;
- NSString *authToken = [SFHFKeychainUtils getPasswordForUsername:self.username andServiceName:WordPressComOAuthKeychainServiceName error:&error];
-
- if (error) {
- DDLogError(@"Error while retrieving WordPressComOAuthKeychainServiceName token: %@", error);
- }
-
- return authToken;
+ return [WPAccount tokenForUsername:self.username];
}
- (void)setAuthToken:(NSString *)authToken
@@ -93,10 +83,11 @@ - (void)setAuthToken:(NSString *)authToken
NSError *error = nil;
[SFHFKeychainUtils storeUsername:self.username
andPassword:authToken
- forServiceName:WordPressComOAuthKeychainServiceName
+ forServiceName:[WPAccount authKeychainServiceName]
+ accessGroup:nil
updateExisting:YES
error:&error];
-
+
if (error) {
DDLogError(@"Error while updating WordPressComOAuthKeychainServiceName token: %@", error);
}
@@ -104,13 +95,14 @@ - (void)setAuthToken:(NSString *)authToken
} else {
NSError *error = nil;
[SFHFKeychainUtils deleteItemForUsername:self.username
- andServiceName:WordPressComOAuthKeychainServiceName
+ andServiceName:[WPAccount authKeychainServiceName]
+ accessGroup:nil
error:&error];
if (error) {
DDLogError(@"Error while deleting WordPressComOAuthKeychainServiceName token: %@", error);
}
}
-
+
// Make sure to release any RestAPI alloc'ed, since it might have an invalid token
_wordPressComRestApi = nil;
}
@@ -125,10 +117,46 @@ - (NSArray *)visibleBlogs
return [visibleBlogs sortedArrayUsingDescriptors:@[descriptor]];
}
-- (BOOL)isDefault {
- AccountService *service = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext];
- WPAccount *defaultAccount = [service defaultWordPressComAccount];
- return [defaultAccount isEqual:self];
+- (BOOL)hasAtomicSite {
+ for (Blog *blog in self.blogs) {
+ if ([blog isAtomic]) {
+ return YES;
+ }
+ }
+ return NO;
+}
+
+#pragma mark - Static methods
+
++ (NSString *)tokenForUsername:(NSString *)username
+{
+ NSError *error = nil;
+ [WPAccount migrateAuthKeyForUsername:username];
+ NSString *authToken = [SFHFKeychainUtils getPasswordForUsername:username
+ andServiceName:[WPAccount authKeychainServiceName]
+ accessGroup:nil
+ error:&error];
+ if (error) {
+ DDLogError(@"Error while retrieving WordPressComOAuthKeychainServiceName token: %@", error);
+ }
+
+ return authToken;
+}
+
++ (void)migrateAuthKeyForUsername:(NSString *)username
+{
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ if ([AppConfiguration isJetpack]) {
+ SharedDataIssueSolver *sharedDataIssueSolver = [SharedDataIssueSolver instance];
+ [sharedDataIssueSolver migrateAuthKeyFor:username];
+ }
+ });
+}
+
++ (NSString *)authKeychainServiceName
+{
+ return [AppConstants authKeychainServiceName];
}
#pragma mark - API Helpers
@@ -144,7 +172,7 @@ - (WordPressComRestApi *)wordPressComRestApi
[_wordPressComRestApi setInvalidTokenHandler:^{
[weakSelf setAuthToken:nil];
[WordPressAuthenticationManager showSigninForWPComFixingAuthToken];
- if (weakSelf.isDefault) {
+ if (weakSelf.isDefaultWordPressComAccount) {
[[NSNotificationCenter defaultCenter] postNotificationName:WPAccountDefaultWordPressComAccountChangedNotification object:weakSelf];
}
}];
diff --git a/WordPress/Classes/Models/WPCommentContentViewProvider.h b/WordPress/Classes/Models/WPCommentContentViewProvider.h
deleted file mode 100644
index cefc561b57ba..000000000000
--- a/WordPress/Classes/Models/WPCommentContentViewProvider.h
+++ /dev/null
@@ -1,11 +0,0 @@
-#import
-#import "PostContentProvider.h"
-
-@protocol WPCommentContentViewProvider
-
-- (BOOL)isLiked;
-- (BOOL)authorIsPostAuthor;
-- (NSNumber *)numberOfLikes;
-- (BOOL)isPrivateContent;
-
-@end
diff --git a/WordPress/Classes/Networking/MediaHost+AbstractPost.swift b/WordPress/Classes/Networking/MediaHost+AbstractPost.swift
new file mode 100644
index 000000000000..57a656ab373c
--- /dev/null
+++ b/WordPress/Classes/Networking/MediaHost+AbstractPost.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily
+/// initialize it from a given `AbstractPost`.
+///
+extension MediaHost {
+ enum AbstractPostError: Swift.Error {
+ case baseInitializerError(error: BlogError, post: AbstractPost)
+ }
+
+ init(with post: AbstractPost, failure: (AbstractPostError) -> ()) {
+ self.init(
+ with: post.blog,
+ failure: { error in
+ // We just associate a post with the underlying error for simpler debugging.
+ failure(AbstractPostError.baseInitializerError(
+ error: error,
+ post: post))
+ })
+ }
+}
diff --git a/WordPress/Classes/Networking/MediaHost+Blog.swift b/WordPress/Classes/Networking/MediaHost+Blog.swift
new file mode 100644
index 000000000000..e6366f3d6769
--- /dev/null
+++ b/WordPress/Classes/Networking/MediaHost+Blog.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily
+/// initialize it from a given `Blog`.
+///
+extension MediaHost {
+ enum BlogError: Swift.Error {
+ case baseInitializerError(error: Error, blog: Blog)
+ }
+
+ init(with blog: Blog, failure: (BlogError) -> ()) {
+ let isAtomic = blog.isAtomic()
+ self.init(with: blog, isAtomic: isAtomic, failure: failure)
+ }
+
+ init(with blog: Blog, isAtomic: Bool, failure: (BlogError) -> ()) {
+ self.init(isAccessibleThroughWPCom: blog.isAccessibleThroughWPCom(),
+ isPrivate: blog.isPrivate(),
+ isAtomic: isAtomic,
+ siteID: blog.dotComID?.intValue,
+ username: blog.usernameForSite,
+ authToken: blog.authToken,
+ failure: { error in
+ // We just associate a blog with the underlying error for simpler debugging.
+ failure(BlogError.baseInitializerError(
+ error: error,
+ blog: blog))
+ })
+ }
+}
diff --git a/WordPress/Classes/Networking/MediaHost+ReaderPostContentProvider.swift b/WordPress/Classes/Networking/MediaHost+ReaderPostContentProvider.swift
new file mode 100644
index 000000000000..7af84a15507b
--- /dev/null
+++ b/WordPress/Classes/Networking/MediaHost+ReaderPostContentProvider.swift
@@ -0,0 +1,37 @@
+import Foundation
+
+/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily
+/// initialize it from a given `Blog`.
+///
+extension MediaHost {
+ enum ReaderPostContentProviderError: Swift.Error {
+ case noDefaultWordPressComAccount
+ case baseInitializerError(error: Error, readerPostContentProvider: ReaderPostContentProvider)
+ }
+
+ init(with readerPostContentProvider: ReaderPostContentProvider, failure: (ReaderPostContentProviderError) -> ()) {
+ let isAccessibleThroughWPCom = readerPostContentProvider.isWPCom() || readerPostContentProvider.isJetpack()
+
+ // This is the only way in which we can obtain the username and authToken here.
+ // It'd be nice if all data was associated with an account instead, for transparency
+ // and cleanliness of the code - but this'll have to do for now.
+
+ // We allow a nil account in case the user connected only self-hosted sites.
+ let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)
+ let username = account?.username
+ let authToken = account?.authToken
+
+ self.init(isAccessibleThroughWPCom: isAccessibleThroughWPCom,
+ isPrivate: readerPostContentProvider.isPrivate(),
+ isAtomic: readerPostContentProvider.isAtomic(),
+ siteID: readerPostContentProvider.siteID()?.intValue,
+ username: username,
+ authToken: authToken,
+ failure: { error in
+ // We just associate a ReaderPostContentProvider with the underlying error for simpler debugging.
+ failure(ReaderPostContentProviderError.baseInitializerError(
+ error: error,
+ readerPostContentProvider: readerPostContentProvider))
+ })
+ }
+}
diff --git a/WordPress/Classes/Networking/MediaHost.swift b/WordPress/Classes/Networking/MediaHost.swift
new file mode 100644
index 000000000000..12741a6396c7
--- /dev/null
+++ b/WordPress/Classes/Networking/MediaHost.swift
@@ -0,0 +1,93 @@
+import Foundation
+
+/// Defines a media host for request authentication purposes.
+///
+enum MediaHost: Equatable {
+ case publicSite
+ case publicWPComSite
+ case privateSelfHostedSite
+ case privateWPComSite(authToken: String)
+ case privateAtomicWPComSite(siteID: Int, username: String, authToken: String)
+
+ enum Error: Swift.Error {
+ case wpComWithoutSiteID
+ case wpComPrivateSiteWithoutAuthToken
+ case wpComPrivateSiteWithoutUsername
+ }
+
+ init(
+ isAccessibleThroughWPCom: Bool,
+ isPrivate: Bool,
+ isAtomic: Bool,
+ siteID: Int?,
+ username: String?,
+ authToken: String?,
+ failure: (Error) -> Void) {
+
+ guard isPrivate else {
+ if isAccessibleThroughWPCom {
+ self = .publicWPComSite
+ } else {
+ self = .publicSite
+ }
+ return
+ }
+
+ guard isAccessibleThroughWPCom else {
+ self = .privateSelfHostedSite
+ return
+ }
+
+ guard let authToken = authToken else {
+ // This should actually not be possible. We have no good way to
+ // handle this.
+ failure(Error.wpComPrivateSiteWithoutAuthToken)
+
+ // If the caller wants to kill execution, they can do it in the failure block
+ // call above.
+ //
+ // Otherwise they'll be able to continue trying to request the image as if it
+ // was hosted in a public WPCom site. This is the best we can offer with the
+ // provided input parameters.
+ self = .publicSite
+ return
+ }
+
+ guard isAtomic else {
+ self = .privateWPComSite(authToken: authToken)
+ return
+ }
+
+ guard let username = username else {
+ // This should actually not be possible. We have no good way to
+ // handle this.
+ failure(Error.wpComPrivateSiteWithoutUsername)
+
+ // If the caller wants to kill execution, they can do it in the failure block
+ // call above.
+ //
+ // Otherwise they'll be able to continue trying to request the image as if it
+ // was hosted in a private WPCom site. This is the best we can offer with the
+ // provided input parameters.
+ self = .privateWPComSite(authToken: authToken)
+ return
+ }
+
+ guard let siteID = siteID else {
+ // This should actually not be possible. We have no good way to
+ // handle this.
+ failure(Error.wpComWithoutSiteID)
+
+ // If the caller wants to kill execution, they can do it in the failure block
+ // call above.
+ //
+ // Otherwise they'll be able to continue trying to request the image as if it
+ // was hosted in a private WPCom site. This is the best we can offer with the
+ // provided input parameters.
+ self = .privateWPComSite(authToken: authToken)
+ return
+ }
+
+ self = .privateAtomicWPComSite(siteID: siteID, username: username, authToken: authToken)
+ }
+}
diff --git a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift
new file mode 100644
index 000000000000..6ed51533bec8
--- /dev/null
+++ b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift
@@ -0,0 +1,246 @@
+import Foundation
+
+fileprivate let photonHost = "i0.wp.com"
+fileprivate let secureHttpScheme = "https"
+fileprivate let wpComApiHost = "public-api.wordpress.com"
+
+extension URL {
+ /// Whether the URL is a Photon URL.
+ ///
+ fileprivate func isPhoton() -> Bool {
+ return host == photonHost
+ }
+}
+
+/// This class takes care of resolving any authentication necessary before
+/// requesting media from WP sites (both self-hosted and WP.com).
+///
+/// This also includes regular and photon URLs.
+///
+class MediaRequestAuthenticator {
+
+ /// Errors conditions that this class can find.
+ ///
+ enum Error: Swift.Error {
+ case cannotFindSiteIDForSiteAvailableThroughWPCom(blog: Blog)
+ case cannotBreakDownURLIntoComponents(url: URL)
+ case cannotCreateAtomicURL(components: URLComponents)
+ case cannotCreateAtomicProxyURL(components: URLComponents)
+ case cannotCreatePrivateURL(components: URLComponents)
+ case cannotFindWPContentInPhotonPath(components: URLComponents)
+ case failedToLoadAtomicAuthenticationCookies(underlyingError: Swift.Error)
+ }
+
+ // MARK: - Request Authentication
+
+ /// Pass this method a media URL and host information, and it will handle all the necessary
+ /// logic to provide the caller with an authenticated request through the completion closure.
+ ///
+ /// - Parameters:
+ /// - url: the url for the media.
+ /// - host: the `MediaHost` for the requested Media. This is used for authenticating the requests.
+ /// - provide: the closure that will be called once authentication is sorted out by this class.
+ /// The request can be executed directly without having to do anything else in terms of
+ /// authentication.
+ /// - fail: the closure that will be called upon finding an error condition.
+ ///
+ func authenticatedRequest(
+ for url: URL,
+ from host: MediaHost,
+ onComplete provide: @escaping (URLRequest) -> (),
+ onFailure fail: @escaping (Error) -> ()) {
+
+ // We want to make sure we're never sending credentials
+ // to a URL that's not safe.
+ guard !url.isFileURL || url.isHostedAtWPCom || url.isPhoton() else {
+ let request = URLRequest(url: url)
+ provide(request)
+ return
+ }
+
+ switch host {
+ case .publicSite:
+ fallthrough
+ case .publicWPComSite:
+ fallthrough
+ case .privateSelfHostedSite:
+ // The authentication for these is handled elsewhere
+ let request = URLRequest(url: url)
+ provide(request)
+ case .privateWPComSite(let authToken):
+ authenticatedRequestForPrivateSite(
+ for: url,
+ authToken: authToken,
+ onComplete: provide,
+ onFailure: fail)
+ case .privateAtomicWPComSite(let siteID, let username, let authToken):
+ if url.isPhoton() {
+ authenticatedRequestForPrivateAtomicSiteThroughPhoton(
+ for: url,
+ siteID: siteID,
+ authToken: authToken,
+ onComplete: provide,
+ onFailure: fail)
+ } else {
+ authenticatedRequestForPrivateAtomicSite(
+ for: url,
+ siteID: siteID,
+ username: username,
+ authToken: authToken,
+ onComplete: provide,
+ onFailure: fail)
+ }
+ }
+ }
+
+ // MARK: - Request Authentication: Specific Scenarios
+
+ /// Authentication for a WPCom private request.
+ ///
+ /// - Parameters:
+ /// - url: the url for the media.
+ /// - provide: the closure that will be called once authentication is sorted out by this class.
+ /// The request can be executed directly without having to do anything else in terms of
+ /// authentication.
+ /// - fail: the closure that will be called upon finding an error condition.
+ ///
+ private func authenticatedRequestForPrivateSite(
+ for url: URL,
+ authToken: String,
+ onComplete provide: (URLRequest) -> (),
+ onFailure fail: (Error) -> ()) {
+
+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
+ fail(Error.cannotBreakDownURLIntoComponents(url: url))
+ return
+ }
+
+ // Just in case, enforce HTTPs
+ components.scheme = secureHttpScheme
+
+ guard let finalURL = components.url else {
+ fail(Error.cannotCreatePrivateURL(components: components))
+ return
+ }
+
+ let request = tokenAuthenticatedWPComRequest(for: finalURL, authToken: authToken)
+ provide(request)
+ }
+
+ /// Authentication for a WPCom private atomic request.
+ ///
+ /// - Parameters:
+ /// - url: the url for the media.
+ /// - siteID: the ID of the site that owns this media.
+ /// - provide: the closure that will be called once authentication is sorted out by this class.
+ /// The request can be executed directly without having to do anything else in terms of
+ /// authentication.
+ /// - fail: the closure that will be called upon finding an error condition.
+ ///
+ private func authenticatedRequestForPrivateAtomicSite(
+ for url: URL,
+ siteID: Int,
+ username: String,
+ authToken: String,
+ onComplete provide: @escaping (URLRequest) -> (),
+ onFailure fail: @escaping (Error) -> ()) {
+
+ guard url.isHostedAtWPCom,
+ var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
+ provide(URLRequest(url: url))
+ return
+ }
+
+ guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) else {
+ provide(URLRequest(url: url))
+ return
+ }
+
+ let authenticationService = AtomicAuthenticationService(account: account)
+ let cookieJar = HTTPCookieStorage.shared
+
+ // Just in case, enforce HTTPs
+ components.scheme = secureHttpScheme
+
+ guard let finalURL = components.url else {
+ fail(Error.cannotCreateAtomicURL(components: components))
+ return
+ }
+
+ let request = tokenAuthenticatedWPComRequest(for: finalURL, authToken: authToken)
+
+ authenticationService.loadAuthCookies(into: cookieJar, username: account.username, siteID: siteID, success: {
+ provide(request)
+ }) { error in
+ fail(Error.failedToLoadAtomicAuthenticationCookies(underlyingError: error))
+ }
+ }
+
+ /// Authentication for a Photon request in a private atomic site.
+ ///
+ /// - Important: Photon URLs are currently not working for private atomic sites, so this is a workaround
+ /// to replace those URLs with working URLs.
+ ///
+ /// By recommendation of @zieladam we'll be using the Atomic Proxy endpoint for these until
+ /// Photon starts working with Atomic Private Sites:
+ ///
+ /// https://public-api.wordpress.com/wpcom/v2/sites/$siteID/atomic-auth-proxy/file/$wpContentPath
+ ///
+ /// To know whether you can remove this method, try requesting the photon URL from an
+ /// atomic private site. If it works then you can remove this workaround logic.
+ ///
+ /// - Parameters:
+ /// - url: the url for the media.
+ /// - siteID: the ID of the site that owns this media.
+ /// - provide: the closure that will be called once authentication is sorted out by this class.
+ /// The request can be executed directly without having to do anything else in terms of
+ /// authentication.
+ /// - fail: the closure that will be called upon finding an error condition.
+ ///
+ private func authenticatedRequestForPrivateAtomicSiteThroughPhoton(
+ for url: URL,
+ siteID: Int,
+ authToken: String,
+ onComplete provide: @escaping (URLRequest) -> (),
+ onFailure fail: @escaping (Error) -> ()) {
+
+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
+ fail(Error.cannotBreakDownURLIntoComponents(url: url))
+ return
+ }
+
+ guard let wpContentRange = components.path.range(of: "/wp-content") else {
+ fail(Error.cannotFindWPContentInPhotonPath(components: components))
+ return
+ }
+
+ let contentPath = String(components.path[wpContentRange.lowerBound ..< components.path.endIndex])
+
+ components.scheme = secureHttpScheme
+ components.host = wpComApiHost
+ components.path = "/wpcom/v2/sites/\(siteID)/atomic-auth-proxy/file"
+ components.queryItems = [URLQueryItem(name: "path", value: contentPath)]
+
+ guard let finalURL = components.url else {
+ fail(Error.cannotCreateAtomicProxyURL(components: components))
+ return
+ }
+
+ let request = tokenAuthenticatedWPComRequest(for: finalURL, authToken: authToken)
+ provide(request)
+ }
+
+ // MARK: - Adding the Auth Token
+
+ /// Returns a request with the Bearer token for WPCom authentication.
+ ///
+ /// - Parameters:
+ /// - url: the url of the media.
+ /// - authToken: the Bearer token to add to the resulting request.
+ ///
+ private func tokenAuthenticatedWPComRequest(for url: URL, authToken: String) -> URLRequest {
+ var request = URLRequest(url: url)
+ request.addValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
+ return request
+ }
+}
diff --git a/WordPress/Classes/Networking/Pinghub.swift b/WordPress/Classes/Networking/Pinghub.swift
index 6fbc68a655f8..9161cbbc03b3 100644
--- a/WordPress/Classes/Networking/Pinghub.swift
+++ b/WordPress/Classes/Networking/Pinghub.swift
@@ -8,7 +8,7 @@ import Starscream
/// The delegate of a PinghubClient must adopt the PinghubClientDelegate
/// protocol. The client will inform the delegate of any relevant events.
///
-public protocol PinghubClientDelegate: class {
+public protocol PinghubClientDelegate: AnyObject {
/// The client connected successfully.
///
func pingubDidConnect(_ client: PinghubClient)
@@ -222,7 +222,7 @@ extension PinghubClient {
// MARK: - Socket
-internal protocol Socket: class {
+internal protocol Socket: AnyObject {
func connect()
func disconnect()
var onConnect: (() -> Void)? { get set }
diff --git a/WordPress/Classes/Networking/WordPressOrgRestApi+WordPress.swift b/WordPress/Classes/Networking/WordPressOrgRestApi+WordPress.swift
new file mode 100644
index 000000000000..97b51266e797
--- /dev/null
+++ b/WordPress/Classes/Networking/WordPressOrgRestApi+WordPress.swift
@@ -0,0 +1,48 @@
+import Foundation
+import WordPressKit
+
+private func makeAuthenticator(blog: Blog) -> Authenticator? {
+ return blog.account != nil
+ ? makeTokenAuthenticator(blog: blog)
+ : makeCookieNonceAuthenticator(blog: blog)
+}
+
+private func makeTokenAuthenticator(blog: Blog) -> Authenticator? {
+ guard let token = blog.authToken else {
+ DDLogError("Failed to initialize a .com API client with blog: \(blog)")
+ return nil
+ }
+ return TokenAuthenticator(token: token)
+}
+
+private func makeCookieNonceAuthenticator(blog: Blog) -> Authenticator? {
+ guard let loginURL = try? blog.loginUrl().asURL(),
+ let adminURL = try? blog.adminUrl(withPath: "").asURL(),
+ let username = blog.username,
+ let password = blog.password,
+ let version = blog.version as String? else {
+ DDLogError("Failed to initialize a .org API client with blog: \(blog)")
+ return nil
+ }
+
+ return CookieNonceAuthenticator(username: username, password: password, loginURL: loginURL, adminURL: adminURL, version: version)
+}
+
+private func apiBase(blog: Blog) -> URL? {
+ precondition(blog.account == nil, ".com support has not been implemented yet")
+ return try? blog.url(withPath: "wp-json/").asURL()
+}
+
+extension WordPressOrgRestApi {
+ @objc public convenience init?(blog: Blog) {
+ guard let apiBase = apiBase(blog: blog),
+ let authenticator = makeAuthenticator(blog: blog) else {
+ return nil
+ }
+ self.init(
+ apiBase: apiBase,
+ authenticator: authenticator,
+ userAgent: WPUserAgent.wordPress()
+ )
+ }
+}
diff --git a/WordPress/Classes/PropertyWrappers/Atomic.swift b/WordPress/Classes/PropertyWrappers/Atomic.swift
new file mode 100644
index 000000000000..20c907fc2ede
--- /dev/null
+++ b/WordPress/Classes/PropertyWrappers/Atomic.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+@propertyWrapper
+struct Atomic {
+
+ private var value: Value
+ private let lock = NSLock()
+
+ init(wrappedValue value: Value) {
+ self.value = value
+ }
+
+ var wrappedValue: Value {
+ get { return load() }
+ set { store(newValue: newValue) }
+ }
+
+ func load() -> Value {
+ lock.lock()
+ defer { lock.unlock() }
+ return value
+ }
+
+ mutating func store(newValue: Value) {
+ lock.lock()
+ defer { lock.unlock() }
+ value = newValue
+ }
+}
diff --git a/WordPress/Classes/Services/AccountService+Cookies.swift b/WordPress/Classes/Services/AccountService+Cookies.swift
new file mode 100644
index 000000000000..0abaaa99fa4c
--- /dev/null
+++ b/WordPress/Classes/Services/AccountService+Cookies.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+extension AccountService {
+
+ /// Loads the default WordPress account's cookies into shared cookie storage.
+ ///
+ static func loadDefaultAccountCookies() {
+ guard
+ let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext),
+ let auth = RequestAuthenticator(account: account),
+ let url = URL(string: WPComDomain)
+ else {
+ return
+ }
+ auth.request(url: url, cookieJar: HTTPCookieStorage.shared) { _ in
+ // no op
+ }
+ }
+
+}
diff --git a/WordPress/Classes/Services/AccountService+MergeDuplicates.swift b/WordPress/Classes/Services/AccountService+MergeDuplicates.swift
index 36b6e538c3b2..928cd6c2be6e 100644
--- a/WordPress/Classes/Services/AccountService+MergeDuplicates.swift
+++ b/WordPress/Classes/Services/AccountService+MergeDuplicates.swift
@@ -3,22 +3,20 @@ import Foundation
extension AccountService {
func mergeDuplicatesIfNecessary() {
- guard numberOfAccounts() > 1 else {
- return
- }
-
- let accounts = allAccounts()
- let accountGroups = Dictionary(grouping: accounts) { $0.userID }
- for group in accountGroups.values where group.count > 1 {
- mergeDuplicateAccounts(accounts: group)
- }
-
- if managedObjectContext.hasChanges {
- ContextManager.sharedInstance().save(managedObjectContext)
+ coreDataStack.performAndSave { context in
+ guard let count = try? WPAccount.lookupNumberOfAccounts(in: context), count > 1 else {
+ return
+ }
+
+ let accounts = (try? WPAccount.lookupAllAccounts(in: context)) ?? []
+ let accountGroups = Dictionary(grouping: accounts) { $0.userID }
+ for group in accountGroups.values where group.count > 1 {
+ self.mergeDuplicateAccounts(accounts: group, in: context)
+ }
}
}
- private func mergeDuplicateAccounts(accounts: [WPAccount]) {
+ private func mergeDuplicateAccounts(accounts: [WPAccount], in context: NSManagedObjectContext) {
// For paranoia
guard accounts.count > 1 else {
return
@@ -27,22 +25,21 @@ extension AccountService {
// If one of the accounts is the default account, merge the rest into it.
// Otherwise just use the first account.
var destination = accounts.first!
- if let defaultAccount = defaultWordPressComAccount(), accounts.contains(defaultAccount) {
+ if let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: context), accounts.contains(defaultAccount) {
destination = defaultAccount
}
for account in accounts where account != destination {
- mergeAccount(account: account, into: destination)
+ mergeAccount(account: account, into: destination, in: context)
}
- let service = BlogService(managedObjectContext: managedObjectContext)
- service.deduplicateBlogs(for: destination)
+ destination.deduplicateBlogs()
}
- private func mergeAccount(account: WPAccount, into destination: WPAccount) {
+ private func mergeAccount(account: WPAccount, into destination: WPAccount, in context: NSManagedObjectContext) {
// Move all blogs to the destination account
destination.addBlogs(account.blogs)
- managedObjectContext.delete(account)
+ context.deleteObject(account)
}
}
diff --git a/WordPress/Classes/Services/AccountService.h b/WordPress/Classes/Services/AccountService.h
index 512080237ffd..ed391eb47118 100644
--- a/WordPress/Classes/Services/AccountService.h
+++ b/WordPress/Classes/Services/AccountService.h
@@ -1,5 +1,5 @@
#import
-#import "LocalCoreDataService.h"
+#import "CoreDataService.h"
NS_ASSUME_NONNULL_BEGIN
@@ -9,23 +9,12 @@ NS_ASSUME_NONNULL_BEGIN
extern NSString *const WPAccountDefaultWordPressComAccountChangedNotification;
extern NSNotificationName const WPAccountEmailAndDefaultBlogUpdatedNotification;
-@interface AccountService : LocalCoreDataService
+@interface AccountService : CoreDataService
///------------------------------------
/// @name Default WordPress.com account
///------------------------------------
-/**
- Returns the default WordPress.com account
-
- The default WordPress.com account is the one used for Reader and Notifications
-
- @return the default WordPress.com account
- @see setDefaultWordPressComAccount:
- @see removeDefaultWordPressComAccount
- */
-- (nullable WPAccount *)defaultWordPressComAccount;
-
/**
Sets the default WordPress.com account
@@ -43,11 +32,6 @@ extern NSNotificationName const WPAccountEmailAndDefaultBlogUpdatedNotification;
*/
- (void)removeDefaultWordPressComAccount;
-/**
- Returns if the given account is the default WordPress.com account.
- */
-- (BOOL)isDefaultWordPressComAccount:(WPAccount *)account;
-
/**
Query to check if an email address is paired to a wpcom account. Used in the
magic links signup flow.
@@ -93,35 +77,9 @@ extern NSNotificationName const WPAccountEmailAndDefaultBlogUpdatedNotification;
@param username the WordPress.com account's username
@param authToken the OAuth2 token returned by signIntoWordPressDotComWithUsername:authToken:
- @return a WordPress.com `WPAccount` object for the given `username`
- */
-- (WPAccount *)createOrUpdateAccountWithUsername:(NSString *)username
- authToken:(NSString *)authToken;
-
-- (NSUInteger)numberOfAccounts;
-
-/**
- Returns all accounts currently existing in core data.
-
- @return An array of WPAccounts.
- */
-- (NSArray *)allAccounts;
-
-/**
- Returns a WordPress.com account with the specified username, if it exists
-
- @param username the account's username
- @return a `WPAccount` object if there's one for the specified username. Otherwise it returns nil
- */
-- (nullable WPAccount *)findAccountWithUsername:(NSString *)username;
-
-/**
- Returns a WordPress.com account with the specified user ID, if it exists
-
- @param userID the account's user ID
- @return a `WPAccount` object if there's one for the specified username. Otherwise it returns nil
+ @return The ID of the WordPress.com `WPAccount` object for the given `username`
*/
-- (nullable WPAccount *)findAccountWithUserID:(NSNumber *)userID;
+- (NSManagedObjectID *)createOrUpdateAccountWithUsername:(NSString *)username authToken:(NSString *)authToken;
/**
Updates user details including username, email, userID, avatarURL, and default blog.
@@ -136,7 +94,7 @@ extern NSNotificationName const WPAccountEmailAndDefaultBlogUpdatedNotification;
Updates the default blog for the specified account. The default blog will be the one whose siteID matches
the accounts primaryBlogID.
*/
-- (void)updateDefaultBlogIfNeeded:(WPAccount *)account;
+- (void)updateDefaultBlogIfNeeded:(WPAccount *)account inContext:(NSManagedObjectContext *)context;
/**
Syncs the details for the account associated with the provided auth token, then
diff --git a/WordPress/Classes/Services/AccountService.m b/WordPress/Classes/Services/AccountService.m
index cf20e988b734..2e5660c0b7c3 100644
--- a/WordPress/Classes/Services/AccountService.m
+++ b/WordPress/Classes/Services/AccountService.m
@@ -1,6 +1,6 @@
#import "AccountService.h"
#import "WPAccount.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "Blog.h"
#import "BlogService.h"
#import "TodayExtensionService.h"
@@ -22,30 +22,6 @@ @implementation AccountService
/// @name Default WordPress.com account
///------------------------------------
-/**
- Returns the default WordPress.com account
-
- The default WordPress.com account is the one used for Reader and Notifications
-
- @return the default WordPress.com account
- @see setDefaultWordPressComAccount:
- @see removeDefaultWordPressComAccount
- */
-- (WPAccount *)defaultWordPressComAccount
-{
- NSString *uuid = [[NSUserDefaults standardUserDefaults] stringForKey:DefaultDotcomAccountUUIDDefaultsKey];
- if (uuid.length > 0) {
- WPAccount *account = [self accountWithUUID:uuid];
- if (account) {
- return account;
- }
- }
-
- // No account, or no default account set. Clear the defaults key.
- [[NSUserDefaults standardUserDefaults] removeObjectForKey:DefaultDotcomAccountUUIDDefaultsKey];
- return nil;
-}
-
/**
Sets the default WordPress.com account
@@ -58,19 +34,19 @@ - (void)setDefaultWordPressComAccount:(WPAccount *)account
NSParameterAssert(account != nil);
NSAssert(account.authToken.length > 0, @"Account should have an authToken for WP.com");
- if ([[self defaultWordPressComAccount] isEqual:account]) {
+ if ([account isDefaultWordPressComAccount]) {
return;
}
- [[NSUserDefaults standardUserDefaults] setObject:account.uuid forKey:DefaultDotcomAccountUUIDDefaultsKey];
+ [[UserPersistentStoreFactory userDefaultsInstance] setObject:account.uuid forKey:DefaultDotcomAccountUUIDDefaultsKey];
NSManagedObjectID *accountID = account.objectID;
void (^notifyAccountChange)(void) = ^{
- NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] mainContext];
+ NSManagedObjectContext *mainContext = self.coreDataStack.mainContext;
NSManagedObject *accountInContext = [mainContext existingObjectWithID:accountID error:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:WPAccountDefaultWordPressComAccountChangedNotification object:accountInContext];
- [[PushNotificationsManager shared] registerForRemoteNotifications];
+ [[PushNotificationsManager shared] setupRemoteNotifications];
};
if ([NSThread isMainThread]) {
// This is meant to help with testing account observers.
@@ -94,13 +70,15 @@ - (void)removeDefaultWordPressComAccount
[[PushNotificationsManager shared] unregisterDeviceToken];
- WPAccount *account = [self defaultWordPressComAccount];
+ WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext];
if (account == nil) {
return;
}
- [self.managedObjectContext deleteObject:account];
- [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ WPAccount *accountInContext = [context existingObjectWithID:account.objectID error:nil];
+ [context deleteObject:accountInContext];
+ }];
// Clear WordPress.com cookies
NSArray> *cookieJars = @[
@@ -115,20 +93,12 @@ - (void)removeDefaultWordPressComAccount
[[NSURLCache sharedURLCache] removeAllCachedResponses];
// Remove defaults
- [[NSUserDefaults standardUserDefaults] removeObjectForKey:DefaultDotcomAccountUUIDDefaultsKey];
+ [[UserPersistentStoreFactory userDefaultsInstance] removeObjectForKey:DefaultDotcomAccountUUIDDefaultsKey];
[WPAnalytics refreshMetadata];
[[NSNotificationCenter defaultCenter] postNotificationName:WPAccountDefaultWordPressComAccountChangedNotification object:nil];
}
-- (BOOL)isDefaultWordPressComAccount:(WPAccount *)account {
- NSString *uuid = [[NSUserDefaults standardUserDefaults] stringForKey:DefaultDotcomAccountUUIDDefaultsKey];
- if (uuid.length == 0) {
- return false;
- }
- return [account.uuid isEqualToString:uuid];
-}
-
- (void)isEmailAvailable:(NSString *)email success:(void (^)(BOOL available))success failure:(void (^)(NSError *error))failure
{
id remote = [self remoteForAnonymous];
@@ -161,7 +131,10 @@ - (void)isUsernameAvailable:(NSString *)username
- (void)requestVerificationEmail:(void (^)(void))success failure:(void (^)(NSError * _Nonnull))failure
{
- id remote = [self remoteForAccount:[self defaultWordPressComAccount]];
+ NSAssert([NSThread isMainThread], @"This method should only be called from the main thread");
+
+ WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext];
+ id remote = [self remoteForAccount:account];
[remote requestVerificationEmailWithSucccess:^{
if (success) {
success();
@@ -178,20 +151,28 @@ - (void)requestVerificationEmail:(void (^)(void))success failure:(void (^)(NSErr
/// @name Account creation
///-----------------------
-- (WPAccount *)createOrUpdateAccountWithUserDetails:(RemoteUser *)remoteUser authToken:(NSString *)authToken
+- (NSManagedObjectID *)createOrUpdateAccountWithUserDetails:(RemoteUser *)remoteUser authToken:(NSString *)authToken
{
- WPAccount *account = [self findAccountWithUserID:remoteUser.userID];
- if (account) {
- // Even if we find an account via its userID we should still update
- // its authtoken, otherwise the Authenticator's authtoken fixer won't
- // work.
- account.authToken = authToken;
+ NSManagedObjectID * __block accountObjectID = nil;
+ [self.coreDataStack.mainContext performBlockAndWait:^{
+ accountObjectID = [[WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext] objectID];
+ }];
+
+ if (accountObjectID) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ WPAccount *account = [context existingObjectWithID:accountObjectID error:nil];
+ // Even if we find an account via its userID we should still update
+ // its authtoken, otherwise the Authenticator's authtoken fixer won't
+ // work.
+ account.authToken = authToken;
+ }];
} else {
- NSString *username = remoteUser.username;
- account = [self createOrUpdateAccountWithUsername:username authToken:authToken];
+ accountObjectID = [self createOrUpdateAccountWithUsername:remoteUser.username authToken:authToken];
}
- [self updateAccount:account withUserDetails:remoteUser];
- return account;
+
+ [self updateAccountWithID:accountObjectID withUserDetails:remoteUser];
+
+ return accountObjectID;
}
/**
@@ -203,55 +184,36 @@ - (WPAccount *)createOrUpdateAccountWithUserDetails:(RemoteUser *)remoteUser aut
@param username the WordPress.com account's username
@param authToken the OAuth2 token returned by signIntoWordPressDotComWithUsername:authToken:
- @return a WordPress.com `WPAccount` object for the given `username`
+ @return The ID of the WordPress.com `WPAccount` object for the given `username`
@see createOrUpdateWordPressComAccountWithUsername:password:authToken:
*/
-- (WPAccount *)createOrUpdateAccountWithUsername:(NSString *)username
- authToken:(NSString *)authToken
+- (NSManagedObjectID *)createOrUpdateAccountWithUsername:(NSString *)username authToken:(NSString *)authToken
{
- WPAccount *account = [self findAccountWithUsername:username];
-
- if (!account) {
- account = [NSEntityDescription insertNewObjectForEntityForName:@"Account" inManagedObjectContext:self.managedObjectContext];
- account.uuid = [[NSUUID new] UUIDString];
- account.username = username;
- }
- account.authToken = authToken;
- [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext];
-
- if (![self defaultWordPressComAccount]) {
- [self setDefaultWordPressComAccount:account];
- dispatch_async(dispatch_get_main_queue(), ^{
- [WPAnalytics refreshMetadata];
- });
- }
-
- return account;
-}
+ NSManagedObjectID * __block objectID = nil;
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ WPAccount *account = [WPAccount lookupWithUsername:username context:context];
+ if (!account) {
+ account = [NSEntityDescription insertNewObjectForEntityForName:@"Account" inManagedObjectContext:context];
+ account.uuid = [[NSUUID new] UUIDString];
+ account.username = username;
+ }
+ account.authToken = authToken;
+ [context obtainPermanentIDsForObjects:@[account] error:nil];
+ objectID = account.objectID;
+ }];
-- (NSUInteger)numberOfAccounts
-{
- NSFetchRequest *request = [[NSFetchRequest alloc] init];
- [request setEntity:[NSEntityDescription entityForName:@"Account" inManagedObjectContext:self.managedObjectContext]];
- [request setIncludesSubentities:NO];
-
- NSError *error;
- NSUInteger count = [self.managedObjectContext countForFetchRequest:request error:&error];
- if (count == NSNotFound) {
- count = 0;
- }
- return count;
-}
+ [self.coreDataStack.mainContext performBlockAndWait:^{
+ WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext];
+ if (!defaultAccount) {
+ WPAccount *account = [self.coreDataStack.mainContext existingObjectWithID:objectID error:nil];
+ [self setDefaultWordPressComAccount:account];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [WPAnalytics refreshMetadata];
+ });
+ }
+ }];
-- (NSArray *)allAccounts
-{
- NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Account"];
- NSError *error = nil;
- NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error];
- if (error) {
- return @[];
- }
- return fetchedObjects;
+ return objectID;
}
/**
@@ -276,42 +238,28 @@ - (BOOL)accountHasOnlyJetpackBlogs:(WPAccount *)account
return YES;
}
-- (WPAccount *)accountWithUUID:(NSString *)uuid
-{
- NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Account"];
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"uuid == %@", uuid];
- fetchRequest.predicate = predicate;
-
- NSError *error = nil;
- NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error];
- if (fetchedObjects.count > 0) {
- WPAccount *defaultAccount = fetchedObjects.firstObject;
- defaultAccount.displayName = [defaultAccount.displayName stringByDecodingXMLCharacters];
- return defaultAccount;
- }
- return nil;
-}
-
- (void)restoreDisassociatedAccountIfNecessary
{
- if ([self defaultWordPressComAccount]) {
+ NSAssert([NSThread isMainThread], @"This method should only be called from the main thread");
+
+ if([WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext] != nil) {
return;
}
// Attempt to restore a default account that has somehow been disassociated.
- WPAccount *account = [self findDefaultAccountCandidate];
+ WPAccount *account = [self findDefaultAccountCandidateFromAccounts:[WPAccount lookupAllAccountsInContext:self.coreDataStack.mainContext]];
if (account) {
// Assume we have a good candidate account and make it the default account in the app.
// Note that this should be the account with the most blogs.
// Updates user defaults here vs the setter method to avoid potential side-effects from dispatched notifications.
- [[NSUserDefaults standardUserDefaults] setObject:account.uuid forKey:DefaultDotcomAccountUUIDDefaultsKey];
+ [[UserPersistentStoreFactory userDefaultsInstance] setObject:account.uuid forKey:DefaultDotcomAccountUUIDDefaultsKey];
}
}
-- (WPAccount *)findDefaultAccountCandidate
+- (WPAccount *)findDefaultAccountCandidateFromAccounts:(NSArray *)allAccounts
{
NSSortDescriptor *sort = [NSSortDescriptor sortDescriptorWithKey:@"blogs.@count" ascending:NO];
- NSArray *accounts = [[self allAccounts] sortedArrayUsingDescriptors:@[sort]];
+ NSArray *accounts = [allAccounts sortedArrayUsingDescriptors:@[sort]];
for (WPAccount *account in accounts) {
// Skip accounts that were likely added to Jetpack-connected self-hosted
@@ -324,26 +272,6 @@ - (WPAccount *)findDefaultAccountCandidate
return nil;
}
-- (WPAccount *)findAccountWithUsername:(NSString *)username
-{
- NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Account"];
- [request setPredicate:[NSPredicate predicateWithFormat:@"username =[c] %@ || email =[c] %@", username, username]];
- [request setIncludesPendingChanges:YES];
-
- NSArray *results = [self.managedObjectContext executeFetchRequest:request error:nil];
- return [results firstObject];
-}
-
-- (WPAccount *)findAccountWithUserID:(NSNumber *)userID
-{
- NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Account"];
- [request setPredicate:[NSPredicate predicateWithFormat:@"userID = %@", userID]];
- [request setIncludesPendingChanges:YES];
-
- NSArray *results = [self.managedObjectContext executeFetchRequest:request error:nil];
- return [results firstObject];
-}
-
- (void)createOrUpdateAccountWithAuthToken:(NSString *)authToken
success:(void (^)(WPAccount * _Nonnull))success
failure:(void (^)(NSError * _Nonnull))failure
@@ -351,7 +279,11 @@ - (void)createOrUpdateAccountWithAuthToken:(NSString *)authToken
WordPressComRestApi *api = [WordPressComRestApi defaultApiWithOAuthToken:authToken userAgent:[WPUserAgent defaultUserAgent] localeKey:[WordPressComRestApi LocaleKeyDefault]];
AccountServiceRemoteREST *remote = [[AccountServiceRemoteREST alloc] initWithWordPressComRestApi:api];
[remote getAccountDetailsWithSuccess:^(RemoteUser *remoteUser) {
- WPAccount *account = [self createOrUpdateAccountWithUserDetails:remoteUser authToken:authToken];
+ NSManagedObjectID *objectID = [self createOrUpdateAccountWithUserDetails:remoteUser authToken:authToken];
+ WPAccount * __block account = nil;
+ [self.coreDataStack.mainContext performBlockAndWait:^{
+ account = [self.coreDataStack.mainContext existingObjectWithID:objectID error:nil];
+ }];
success(account);
} failure:^(NSError *error) {
failure(error);
@@ -365,12 +297,10 @@ - (void)updateUserDetailsForAccount:(WPAccount *)account
NSAssert(account, @"Account can not be nil");
NSAssert(account.username, @"account.username can not be nil");
- NSString *username = account.username;
id remote = [self remoteForAccount:account];
[remote getAccountDetailsWithSuccess:^(RemoteUser *remoteUser) {
// account.objectID can be temporary, so fetch via username/xmlrpc instead.
- WPAccount *fetchedAccount = [self findAccountWithUsername:username];
- [self updateAccount:fetchedAccount withUserDetails:remoteUser];
+ [self updateAccountWithID:account.objectID withUserDetails:remoteUser];
dispatch_async(dispatch_get_main_queue(), ^{
[WPAnalytics refreshMetadata];
if (success) {
@@ -402,24 +332,33 @@ - (void)updateUserDetailsForAccount:(WPAccount *)account
return [[AccountServiceRemoteREST alloc] initWithWordPressComRestApi:account.wordPressComRestApi];
}
-- (void)updateAccount:(WPAccount *)account withUserDetails:(RemoteUser *)userDetails
+- (void)updateAccountWithID:(NSManagedObjectID *)objectID withUserDetails:(RemoteUser *)userDetails
{
- account.userID = userDetails.userID;
- account.username = userDetails.username;
- account.email = userDetails.email;
- account.avatarURL = userDetails.avatarURL;
- account.displayName = userDetails.displayName;
- account.dateCreated = userDetails.dateCreated;
- account.emailVerified = @(userDetails.emailVerified);
- account.primaryBlogID = userDetails.primaryBlogID;
-
- [self updateDefaultBlogIfNeeded: account];
-
- [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext];
+ NSParameterAssert(![objectID isTemporaryID]);
+
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ WPAccount *account = [context existingObjectWithID:objectID error:nil];
+ account.userID = userDetails.userID;
+ account.username = userDetails.username;
+ account.email = userDetails.email;
+ account.avatarURL = userDetails.avatarURL;
+ account.displayName = userDetails.displayName;
+ account.dateCreated = userDetails.dateCreated;
+ account.emailVerified = @(userDetails.emailVerified);
+ account.primaryBlogID = userDetails.primaryBlogID;
+ }];
+
+ // Make sure the account is saved before updating its default blog.
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ WPAccount *account = [context existingObjectWithID:objectID error:nil];
+ [self updateDefaultBlogIfNeeded:account inContext:context];
+ }];
}
-- (void)updateDefaultBlogIfNeeded:(WPAccount *)account
+- (void)updateDefaultBlogIfNeeded:(WPAccount *)account inContext:(NSManagedObjectContext *)context
{
+ NSParameterAssert(account.managedObjectContext == context);
+
if (!account.primaryBlogID || [account.primaryBlogID intValue] == 0) {
return;
}
@@ -437,15 +376,29 @@ - (void)updateDefaultBlogIfNeeded:(WPAccount *)account
account.defaultBlog = defaultBlog;
// Update app extensions if needed.
- if (account == [self defaultWordPressComAccount]) {
- [self setupAppExtensionsWithDefaultAccount];
+ if ([account isDefaultWordPressComAccount]) {
+ [self setupAppExtensionsWithDefaultAccount:account inContext:context];
}
}
- (void)setupAppExtensionsWithDefaultAccount
{
- WPAccount *defaultAccount = [self defaultWordPressComAccount];
- Blog *defaultBlog = [defaultAccount defaultBlog];
+ NSManagedObjectContext *context = self.coreDataStack.mainContext;
+ [context performBlockAndWait:^{
+ WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:context];
+ if (account == nil) {
+ return;
+ }
+ [self setupAppExtensionsWithDefaultAccount:account inContext:context];
+ }];
+}
+
+- (void)setupAppExtensionsWithDefaultAccount:(WPAccount *)defaultAccount inContext:(NSManagedObjectContext *)context
+{
+ NSParameterAssert(defaultAccount.managedObjectContext == context);
+
+ NSManagedObjectID *defaultAccountObjectID = defaultAccount.objectID;
+ Blog *defaultBlog = [defaultAccount defaultBlog];
NSNumber *siteId = defaultBlog.dotComID;
NSString *blogName = defaultBlog.settings.name;
NSString *blogUrl = defaultBlog.displayURL;
@@ -463,26 +416,27 @@ - (void)setupAppExtensionsWithDefaultAccount
} else {
// Required Attributes
- BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:self.managedObjectContext];
NSString *oauth2Token = defaultAccount.authToken;
// For the Today Extensions, if the user has set a non-primary site, use that.
NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:WPAppGroupName];
- NSNumber *todayExtensionSiteID = [sharedDefaults objectForKey:WPStatsTodayWidgetUserDefaultsSiteIdKey];
- NSString *todayExtensionBlogName = [sharedDefaults objectForKey:WPStatsTodayWidgetUserDefaultsSiteNameKey];
- NSString *todayExtensionBlogUrl = [sharedDefaults objectForKey:WPStatsTodayWidgetUserDefaultsSiteUrlKey];
+ NSNumber *todayExtensionSiteID = [sharedDefaults objectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteIdKey];
+ NSString *todayExtensionBlogName = [sharedDefaults objectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteNameKey];
+ NSString *todayExtensionBlogUrl = [sharedDefaults objectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteUrlKey];
- Blog *todayExtensionBlog = [blogService blogByBlogId:todayExtensionSiteID];
- NSTimeZone *timeZone = [blogService timeZoneForBlog:todayExtensionBlog];
+ Blog *todayExtensionBlog = [Blog lookupWithID:todayExtensionSiteID in:context];
+ NSTimeZone *timeZone = [todayExtensionBlog timeZone];
- if (todayExtensionSiteID == NULL) {
+ if (todayExtensionSiteID == NULL || todayExtensionBlog == nil) {
todayExtensionSiteID = siteId;
todayExtensionBlogName = blogName;
todayExtensionBlogUrl = blogUrl;
- timeZone = [blogService timeZoneForBlog:defaultBlog];
+ timeZone = [defaultBlog timeZone];
}
dispatch_async(dispatch_get_main_queue(), ^{
+ WPAccount *defaultAccount = [self.coreDataStack.mainContext existingObjectWithID:defaultAccountObjectID error:nil];
+
TodayExtensionService *service = [TodayExtensionService new];
[service configureTodayWidgetWithSiteID:todayExtensionSiteID
blogName:todayExtensionBlogName
@@ -499,6 +453,7 @@ - (void)setupAppExtensionsWithDefaultAccount
[NotificationSupportService insertServiceExtensionToken:defaultAccount.authToken];
[NotificationSupportService insertServiceExtensionUsername:defaultAccount.username];
+ [NotificationSupportService insertServiceExtensionUserID:defaultAccount.userID.stringValue];
});
}
@@ -508,17 +463,24 @@ - (void)purgeAccountIfUnused:(WPAccount *)account
{
NSParameterAssert(account);
- BOOL purge = NO;
- WPAccount *defaultAccount = [self defaultWordPressComAccount];
- if ([account.blogs count] == 0
- && ![defaultAccount isEqual:account]) {
- purge = YES;
- }
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ WPAccount *accountInContext = [context existingObjectWithID:account.objectID error:nil];
+ if (accountInContext == nil) {
+ return;
+ }
- if (purge) {
- DDLogWarn(@"Removing account since it has no blogs associated and it's not the default account: %@", account);
- [self.managedObjectContext deleteObject:account];
- }
+ BOOL purge = NO;
+ WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context];
+ if ([accountInContext.blogs count] == 0
+ && ![defaultAccount isEqual:accountInContext]) {
+ purge = YES;
+ }
+
+ if (purge) {
+ DDLogWarn(@"Removing account since it has no blogs associated and it's not the default account: %@", accountInContext);
+ [context deleteObject:accountInContext];
+ }
+ }];
}
///--------------------
@@ -527,21 +489,35 @@ - (void)purgeAccountIfUnused:(WPAccount *)account
- (void)setVisibility:(BOOL)visible forBlogs:(NSArray *)blogs
{
- NSMutableDictionary *blogVisibility = [NSMutableDictionary dictionaryWithCapacity:blogs.count];
- for (Blog *blog in blogs) {
- NSAssert(blog.dotComID.unsignedIntegerValue > 0, @"blog should have a wp.com ID");
- NSAssert([blog.account isEqual:[self defaultWordPressComAccount]], @"blog should belong to the default account");
- // This shouldn't happen, but just in case, let's not crash if
- // something tries to change visibility for a self hosted
- if (blog.dotComID) {
- blogVisibility[blog.dotComID] = @(visible);
- }
- blog.visible = visible;
- }
- AccountServiceRemoteREST *remote = [self remoteForAccount:[self defaultWordPressComAccount]];
- [remote updateBlogsVisibility:blogVisibility success:nil failure:^(NSError *error) {
- DDLogError(@"Error setting blog visibility: %@", error);
+ NSArray *blogIds = [blogs wp_map:^id(Blog *obj) {
+ return obj.objectID;
}];
+ NSMutableDictionary *blogVisibility = [NSMutableDictionary dictionaryWithCapacity:blogIds.count];
+
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ // `defaultAccount` is only used in the `NSAssert` check below, but in our release builds
+ // `NSAssert` are ignored resulting in `defaultAccount` being unused and the compiler
+ // throwing an error. The `__unused` annotation lets us work aruond that.
+ __unused WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context];
+
+ for (NSManagedObjectID *blogId in blogIds) {
+ Blog *blog = [context existingObjectWithID:blogId error:nil];
+ NSAssert(blog.dotComID.unsignedIntegerValue > 0, @"blog should have a wp.com ID");
+ NSAssert([blog.account isEqual:defaultAccount], @"blog should belong to the default account");
+ // This shouldn't happen, but just in case, let's not crash if
+ // something tries to change visibility for a self hosted
+ if (blog.dotComID) {
+ blogVisibility[blog.dotComID] = @(visible);
+ }
+ blog.visible = visible;
+ }
+ } completion:^{
+ WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext];
+ AccountServiceRemoteREST *remote = [self remoteForAccount:defaultAccount];
+ [remote updateBlogsVisibility:blogVisibility success:nil failure:^(NSError *error) {
+ DDLogError(@"Error setting blog visibility: %@", error);
+ }];
+ } onQueue:dispatch_get_main_queue()];
}
@end
diff --git a/WordPress/Classes/Services/AccountSettingsService.swift b/WordPress/Classes/Services/AccountSettingsService.swift
index f0df01fc98fb..927854ca06cb 100644
--- a/WordPress/Classes/Services/AccountSettingsService.swift
+++ b/WordPress/Classes/Services/AccountSettingsService.swift
@@ -20,6 +20,15 @@ protocol AccountSettingsRemoteInterface {
func changeUsername(to username: String, success: @escaping () -> Void, failure: @escaping () -> Void)
func suggestUsernames(base: String, finished: @escaping ([String]) -> Void)
func updatePassword(_ password: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void)
+ func closeAccount(success: @escaping () -> Void, failure: @escaping (Error) -> Void)
+}
+
+extension AccountSettingsRemoteInterface {
+ func updateSetting(_ change: AccountSettingsChange) async throws {
+ try await withCheckedThrowingContinuation { continuation in
+ self.updateSetting(change, success: continuation.resume, failure: continuation.resume(throwing:))
+ }
+ }
}
extension AccountSettingsRemote: AccountSettingsRemoteInterface {}
@@ -50,24 +59,34 @@ class AccountSettingsService {
var stallTimer: Timer?
- fileprivate let context = ContextManager.sharedInstance().mainContext
+ private let coreDataStack: CoreDataStackSwift
convenience init(userID: Int, api: WordPressComRestApi) {
let remote = AccountSettingsRemote.remoteWithApi(api)
self.init(userID: userID, remote: remote)
}
- init(userID: Int, remote: AccountSettingsRemoteInterface) {
+ init(userID: Int, remote: AccountSettingsRemoteInterface, coreDataStack: CoreDataStackSwift = ContextManager.sharedInstance()) {
self.userID = userID
self.remote = remote
+ self.coreDataStack = coreDataStack
loadSettings()
}
- func getSettingsAttempt(count: Int = 0) {
- self.remote.getSettings(
+ func getSettingsAttempt(count: Int = 0, completion: ((Result) -> Void)? = nil) {
+ remote.getSettings(
success: { settings in
- self.updateSettings(settings)
- self.status = .idle
+ self.coreDataStack.performAndSave({ context in
+ if let managedSettings = self.managedAccountSettingsWithID(self.userID, in: context) {
+ managedSettings.updateWith(settings)
+ } else {
+ self.createAccountSettings(self.userID, settings: settings, in: context)
+ }
+ }, completion: {
+ self.loadSettings()
+ self.status = .idle
+ completion?(.success(settings))
+ }, on: .main)
},
failure: { error in
let error = error as NSError
@@ -77,21 +96,22 @@ class AccountSettingsService {
DDLogError("Error refreshing settings (unrecoverable): \(error)")
}
- if error.domain == NSURLErrorDomain && count < Defaults.maxRetries {
- self.getSettingsAttempt(count: count + 1)
+ if error.domain == NSURLErrorDomain && error.code != URLError.cancelled.rawValue && count < Defaults.maxRetries {
+ self.getSettingsAttempt(count: count + 1, completion: completion)
} else {
self.status = .failed
+ completion?(.failure(error))
}
}
)
}
- func refreshSettings() {
+ func refreshSettings(completion: ((Result) -> Void)? = nil) {
guard status == .idle || status == .failed else {
return
}
status = .refreshing
- getSettingsAttempt()
+ getSettingsAttempt(completion: completion)
stallTimer = Timer.scheduledTimer(timeInterval: Defaults.stallTimeout,
target: self,
selector: #selector(AccountSettingsService.stallTimerFired),
@@ -107,23 +127,33 @@ class AccountSettingsService {
}
func saveChange(_ change: AccountSettingsChange, finished: ((Bool) -> ())? = nil) {
- guard let reverse = try? applyChange(change) else {
+ Task { @MainActor in
+ do {
+ try await saveChange(change)
+ finished?(true)
+ } catch {
+ NotificationCenter.default.post(name: NSNotification.Name.AccountSettingsServiceChangeSaveFailed, object: self, userInfo: [NSUnderlyingErrorKey: error])
+ finished?(false)
+ }
+ }
+ }
+
+ func saveChange(_ change: AccountSettingsChange) async throws {
+ guard let reverse = try? await applyChange(change) else {
return
}
- remote.updateSetting(change, success: {
- finished?(true)
- }) { (error) -> Void in
+ do {
+ try await remote.updateSetting(change)
+ } catch {
do {
// revert change
- try self.applyChange(reverse)
+ try await self.applyChange(reverse)
} catch {
DDLogError("Error reverting change \(error)")
}
DDLogError("Error saving account settings change \(error)")
- NotificationCenter.default.post(name: NSNotification.Name.AccountSettingsServiceChangeSaveFailed, object: self, userInfo: [NSUnderlyingErrorKey: error])
-
- finished?(false)
+ throw error
}
}
@@ -149,11 +179,18 @@ class AccountSettingsService {
}
}
- func primarySiteNameForSettings(_ settings: AccountSettings) -> String? {
- let service = BlogService(managedObjectContext: context)
- let blog = service.blog(byBlogId: NSNumber(value: settings.primarySiteID))
+ func closeAccount(result: @escaping (Result) -> Void) {
+ remote.closeAccount {
+ result(.success(()))
+ } failure: { error in
+ result(.failure(error))
+ }
+ }
- return blog?.settings?.name
+ func primarySiteNameForSettings(_ settings: AccountSettings) -> String? {
+ coreDataStack.performQuery { context in
+ try? Blog.lookup(withID: settings.primarySiteID, in: context)?.settings?.name
+ }
}
/// Change the current user's username
@@ -174,42 +211,36 @@ class AccountSettingsService {
settings = accountSettingsWithID(self.userID)
}
- @discardableResult fileprivate func applyChange(_ change: AccountSettingsChange) throws -> AccountSettingsChange {
- guard let settings = managedAccountSettingsWithID(userID) else {
- DDLogError("Tried to apply a change to nonexistent settings (ID: \(userID)")
- throw Errors.notFound
- }
-
- let reverse = settings.applyChange(change)
- settings.account.applyChange(change)
-
- ContextManager.sharedInstance().save(context)
- loadSettings()
+ @discardableResult fileprivate func applyChange(_ change: AccountSettingsChange) async throws -> AccountSettingsChange {
+ let reverse = try await coreDataStack.performAndSave({ context in
+ guard let settings = self.managedAccountSettingsWithID(self.userID, in: context) else {
+ DDLogError("Tried to apply a change to nonexistent settings (ID: \(self.userID)")
+ throw Errors.notFound
+ }
- return reverse
- }
+ let reverse = settings.applyChange(change)
+ settings.account.applyChange(change)
+ return reverse
+ })
- fileprivate func updateSettings(_ settings: AccountSettings) {
- if let managedSettings = managedAccountSettingsWithID(userID) {
- managedSettings.updateWith(settings)
- } else {
- createAccountSettings(userID, settings: settings)
+ await MainActor.run {
+ self.loadSettings()
}
- ContextManager.sharedInstance().save(context)
- loadSettings()
+ return reverse
}
fileprivate func accountSettingsWithID(_ userID: Int) -> AccountSettings? {
+ coreDataStack.performQuery { context in
+ guard let managedAccount = self.managedAccountSettingsWithID(userID, in: context) else {
+ return nil
+ }
- guard let managedAccount = managedAccountSettingsWithID(userID) else {
- return nil
+ return AccountSettings.init(managed: managedAccount)
}
-
- return AccountSettings.init(managed: managedAccount)
}
- fileprivate func managedAccountSettingsWithID(_ userID: Int) -> ManagedAccountSettings? {
+ fileprivate func managedAccountSettingsWithID(_ userID: Int, in context: NSManagedObjectContext) -> ManagedAccountSettings? {
let request = NSFetchRequest(entityName: ManagedAccountSettings.entityName())
request.predicate = NSPredicate(format: "account.userID = %d", userID)
request.fetchLimit = 1
@@ -219,9 +250,9 @@ class AccountSettingsService {
return results.first
}
- fileprivate func createAccountSettings(_ userID: Int, settings: AccountSettings) {
- let accountService = AccountService(managedObjectContext: context)
- guard let account = accountService.findAccount(withUserID: NSNumber(value: userID)) else {
+ fileprivate func createAccountSettings(_ userID: Int, settings: AccountSettings, in context: NSManagedObjectContext) {
+
+ guard let account = try? WPAccount.lookup(withUserID: Int64(userID), in: context) else {
DDLogError("Tried to create settings for a missing account (ID: \(userID)): \(settings)")
return
}
@@ -254,20 +285,3 @@ class AccountSettingsService {
}
}
}
-
-struct AccountSettingsHelper {
- let accountService: AccountService
-
- init(accountService: AccountService) {
- self.accountService = accountService
- }
-
- func updateTracksOptOutSetting(_ optOut: Bool) {
- guard let account = accountService.defaultWordPressComAccount() else {
- return
- }
-
- let change = AccountSettingsChange.tracksOptOut(optOut)
- AccountSettingsService(userID: account.userID.intValue, api: account.wordPressComRestApi).saveChange(change)
- }
-}
diff --git a/WordPress/Classes/Services/AtomicAuthenticationService.swift b/WordPress/Classes/Services/AtomicAuthenticationService.swift
new file mode 100644
index 000000000000..9153c11e4113
--- /dev/null
+++ b/WordPress/Classes/Services/AtomicAuthenticationService.swift
@@ -0,0 +1,60 @@
+import AutomatticTracks
+import Foundation
+import WordPressKit
+
+class AtomicAuthenticationService {
+
+ let remote: AtomicAuthenticationServiceRemote
+
+ init(remote: AtomicAuthenticationServiceRemote) {
+ self.remote = remote
+ }
+
+ convenience init(account: WPAccount) {
+ let wpComRestApi = account.wordPressComRestV2Api
+ let remote = AtomicAuthenticationServiceRemote(wordPressComRestApi: wpComRestApi)
+
+ self.init(remote: remote)
+ }
+
+ func getAuthCookie(
+ siteID: Int,
+ success: @escaping (_ cookie: HTTPCookie) -> Void,
+ failure: @escaping (Error) -> Void) {
+
+ remote.getAuthCookie(siteID: siteID, success: success, failure: failure)
+ }
+
+ func loadAuthCookies(
+ into cookieJar: CookieJar,
+ username: String,
+ siteID: Int,
+ success: @escaping () -> Void,
+ failure: @escaping (Error) -> Void) {
+
+ cookieJar.hasWordPressComAuthCookie(
+ username: username,
+ atomicSite: true) { hasCookie in
+
+ guard !hasCookie else {
+ success()
+ return
+ }
+
+ self.getAuthCookie(siteID: siteID, success: { cookies in
+ cookieJar.setCookies([cookies]) {
+ success()
+ }
+ }) { error in
+ // Make sure this error scenario isn't silently ignored.
+ WordPressAppDelegate.crashLogging?.logError(error)
+
+ // Even if getting the auth cookies fail, we'll still try to load the URL
+ // so that the user sees a reasonable error situation on screen.
+ // We could opt to create a special screen but for now I'd rather users report
+ // the issue when it happens.
+ failure(error)
+ }
+ }
+ }
+}
diff --git a/WordPress/Classes/Services/AuthenticationService.swift b/WordPress/Classes/Services/AuthenticationService.swift
new file mode 100644
index 000000000000..ca9b401148d3
--- /dev/null
+++ b/WordPress/Classes/Services/AuthenticationService.swift
@@ -0,0 +1,217 @@
+import AutomatticTracks
+import Foundation
+
+class AuthenticationService {
+
+ static let wpComLoginEndpoint = "https://wordpress.com/wp-login.php"
+
+ enum RequestAuthCookieError: Error, LocalizedError {
+ case wpcomCookieNotReturned
+
+ public var errorDescription: String? {
+ switch self {
+ case .wpcomCookieNotReturned:
+ return "Response to request for auth cookie for WP.com site failed to return cookie."
+ }
+ }
+ }
+
+ // MARK: - Self Hosted
+
+ func loadAuthCookiesForSelfHosted(
+ into cookieJar: CookieJar,
+ loginURL: URL,
+ username: String,
+ password: String,
+ success: @escaping () -> Void,
+ failure: @escaping (Error) -> Void) {
+
+ cookieJar.hasWordPressSelfHostedAuthCookie(for: loginURL, username: username) { hasCookie in
+ guard !hasCookie else {
+ success()
+ return
+ }
+
+ self.getAuthCookiesForSelfHosted(loginURL: loginURL, username: username, password: password, success: { cookies in
+ cookieJar.setCookies(cookies) {
+ success()
+ }
+
+ cookieJar.hasWordPressSelfHostedAuthCookie(for: loginURL, username: username) { hasCookie in
+ print("Has cookie: \(hasCookie)")
+ }
+ }) { error in
+ // Make sure this error scenario isn't silently ignored.
+ WordPressAppDelegate.crashLogging?.logError(error)
+
+ // Even if getting the auth cookies fail, we'll still try to load the URL
+ // so that the user sees a reasonable error situation on screen.
+ // We could opt to create a special screen but for now I'd rather users report
+ // the issue when it happens.
+ failure(error)
+ }
+ }
+ }
+
+ func getAuthCookiesForSelfHosted(
+ loginURL: URL,
+ username: String,
+ password: String,
+ success: @escaping (_ cookies: [HTTPCookie]) -> Void,
+ failure: @escaping (Error) -> Void) {
+
+ let headers = [String: String]()
+ let parameters = [
+ "log": username,
+ "pwd": password,
+ "rememberme": "true"
+ ]
+
+ requestAuthCookies(
+ from: loginURL,
+ headers: headers,
+ parameters: parameters,
+ success: success,
+ failure: failure)
+ }
+
+ // MARK: - WP.com
+
+ func loadAuthCookiesForWPCom(
+ into cookieJar: CookieJar,
+ username: String,
+ authToken: String,
+ success: @escaping () -> Void,
+ failure: @escaping (Error) -> Void) {
+
+ cookieJar.hasWordPressComAuthCookie(
+ username: username,
+ atomicSite: false) { hasCookie in
+
+ guard !hasCookie else {
+ // The stored cookie can be stale but we'll try to use it and refresh it if the request fails.
+ success()
+ return
+ }
+
+ self.getAuthCookiesForWPCom(username: username, authToken: authToken, success: { cookies in
+ cookieJar.setCookies(cookies) {
+
+ cookieJar.hasWordPressComAuthCookie(username: username, atomicSite: false) { hasCookie in
+ guard hasCookie else {
+ failure(RequestAuthCookieError.wpcomCookieNotReturned)
+ return
+ }
+ success()
+ }
+
+ }
+ }) { error in
+ // Make sure this error scenario isn't silently ignored.
+ WordPressAppDelegate.crashLogging?.logError(error)
+
+ // Even if getting the auth cookies fail, we'll still try to load the URL
+ // so that the user sees a reasonable error situation on screen.
+ // We could opt to create a special screen but for now I'd rather users report
+ // the issue when it happens.
+ failure(error)
+ }
+ }
+ }
+
+ func getAuthCookiesForWPCom(
+ username: String,
+ authToken: String,
+ success: @escaping (_ cookies: [HTTPCookie]) -> Void,
+ failure: @escaping (Error) -> Void) {
+
+ let loginURL = URL(string: AuthenticationService.wpComLoginEndpoint)!
+ let headers = [
+ "Authorization": "Bearer \(authToken)"
+ ]
+ let parameters = [
+ "log": username,
+ "rememberme": "true"
+ ]
+
+ requestAuthCookies(
+ from: loginURL,
+ headers: headers,
+ parameters: parameters,
+ success: success,
+ failure: failure)
+ }
+
+ // MARK: - Request Construction
+
+ private func requestAuthCookies(
+ from url: URL,
+ headers: [String: String],
+ parameters: [String: String],
+ success: @escaping (_ cookies: [HTTPCookie]) -> Void,
+ failure: @escaping (Error) -> Void) {
+
+ // We don't want these cookies persisted in other sessions
+ let session = URLSession(configuration: .ephemeral)
+ var request = URLRequest(url: url)
+
+ request.httpMethod = "POST"
+ request.httpBody = body(withParameters: parameters)
+
+ headers.forEach { (key, value) in
+ request.setValue(value, forHTTPHeaderField: key)
+ }
+ request.setValue(WPUserAgent.wordPress(), forHTTPHeaderField: "User-Agent")
+
+ let task = session.dataTask(with: request) { data, response, error in
+ if let error = error {
+ DispatchQueue.main.async {
+ failure(error)
+ }
+ return
+ }
+
+ // The following code is a bit complicated to read, apologies.
+ // We're retrieving all cookies from the "Set-Cookie" header manually, and combining
+ // those cookies with the ones from the current session. The reason behind this is that
+ // iOS's URLSession processes the cookies from such header before this callback is executed,
+ // whereas OHTTPStubs.framework doesn't (the cookies are left in the header fields of
+ // the response). The only way to combine both is to just add them together here manually.
+ //
+ // To know if you can remove this, you'll have to test this code live and in our unit tests
+ // and compare the session cookies.
+ let responseCookies = self.cookies(from: response, loginURL: url)
+ let cookies = (session.configuration.httpCookieStorage?.cookies ?? [HTTPCookie]()) + responseCookies
+ DispatchQueue.main.async {
+ success(cookies)
+ }
+ }
+
+ task.resume()
+ }
+
+ private func body(withParameters parameters: [String: String]) -> Data? {
+ var queryItems = [URLQueryItem]()
+
+ for parameter in parameters {
+ let queryItem = URLQueryItem(name: parameter.key, value: parameter.value)
+ queryItems.append(queryItem)
+ }
+
+ var components = URLComponents()
+ components.queryItems = queryItems
+
+ return components.percentEncodedQuery?.data(using: .utf8)
+ }
+
+ // MARK: - Response Parsing
+
+ private func cookies(from response: URLResponse?, loginURL: URL) -> [HTTPCookie] {
+ guard let httpResponse = response as? HTTPURLResponse,
+ let headers = httpResponse.allHeaderFields as? [String: String] else {
+ return []
+ }
+
+ return HTTPCookie.cookies(withResponseHeaderFields: headers, for: loginURL)
+ }
+}
diff --git a/WordPress/Classes/Services/BlazeService.swift b/WordPress/Classes/Services/BlazeService.swift
new file mode 100644
index 000000000000..1e90c5477ec8
--- /dev/null
+++ b/WordPress/Classes/Services/BlazeService.swift
@@ -0,0 +1,71 @@
+import Foundation
+import WordPressKit
+
+@objc final class BlazeService: NSObject {
+
+ private let contextManager: CoreDataStackSwift
+ private let remote: BlazeServiceRemote
+
+ // MARK: - Init
+
+ required init?(contextManager: CoreDataStackSwift = ContextManager.shared,
+ remote: BlazeServiceRemote? = nil) {
+ guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext) else {
+ return nil
+ }
+
+ self.contextManager = contextManager
+ self.remote = remote ?? .init(wordPressComRestApi: account.wordPressComRestV2Api)
+ }
+
+ @objc class func createService() -> BlazeService? {
+ self.init()
+ }
+
+ // MARK: - Methods
+
+ /// Fetches a site's blaze status from the server, and updates the blog's isBlazeApproved property.
+ ///
+ /// - Parameters:
+ /// - blog: A blog
+ /// - completion: Closure to be called on completion
+ @objc func getStatus(for blog: Blog,
+ completion: (() -> Void)? = nil) {
+
+ guard BlazeHelper.isBlazeFlagEnabled() else {
+ updateBlogWithID(blog.objectID, isBlazeApproved: false, completion: completion)
+ return
+ }
+
+ guard let siteId = blog.dotComID?.intValue else {
+ DDLogError("Invalid site ID for Blaze")
+ updateBlogWithID(blog.objectID, isBlazeApproved: false, completion: completion)
+ return
+ }
+
+ remote.getStatus(forSiteId: siteId) { result in
+ switch result {
+ case .success(let approved):
+ self.updateBlogWithID(blog.objectID, isBlazeApproved: approved, completion: completion)
+ case .failure(let error):
+ DDLogError("Unable to fetch isBlazeApproved value from remote: \(error.localizedDescription)")
+ self.updateBlogWithID(blog.objectID, isBlazeApproved: false, completion: completion)
+ }
+ }
+ }
+
+ private func updateBlogWithID(_ objectID: NSManagedObjectID,
+ isBlazeApproved: Bool,
+ completion: (() -> Void)? = nil) {
+ contextManager.performAndSave({ context in
+ guard let blog = try? context.existingObject(with: objectID) as? Blog else {
+ DDLogError("Unable to fetch blog and update isBlazedApproved value")
+ return
+ }
+ blog.isBlazeApproved = isBlazeApproved
+ DDLogInfo("Successfully updated isBlazeApproved value for blog: \(isBlazeApproved)")
+ }, completion: {
+ completion?()
+ }, on: .main)
+ }
+}
diff --git a/WordPress/Classes/Services/BlockEditorSettingsService.swift b/WordPress/Classes/Services/BlockEditorSettingsService.swift
new file mode 100644
index 000000000000..ff0392b07eb4
--- /dev/null
+++ b/WordPress/Classes/Services/BlockEditorSettingsService.swift
@@ -0,0 +1,205 @@
+import Foundation
+import WordPressKit
+
+class BlockEditorSettingsService {
+ struct SettingsServiceResult {
+ let hasChanges: Bool
+ let blockEditorSettings: BlockEditorSettings?
+ }
+
+ enum BlockEditorSettingsServiceError: Int, Error {
+ case blogNotFound
+ }
+
+ typealias BlockEditorSettingsServiceCompletion = (Swift.Result) -> Void
+
+ let blog: Blog
+ let remote: BlockEditorSettingsServiceRemote
+ let coreDataStack: CoreDataStackSwift
+
+ var cachedSettings: BlockEditorSettings? {
+ return blog.blockEditorSettings
+ }
+
+ convenience init?(blog: Blog, coreDataStack: CoreDataStackSwift) {
+ let remoteAPI: WordPressRestApi
+ if blog.isAccessibleThroughWPCom(),
+ blog.dotComID?.intValue != nil,
+ let restAPI = blog.wordPressComRestApi() {
+ remoteAPI = restAPI
+ } else if let orgAPI = blog.wordPressOrgRestApi {
+ remoteAPI = orgAPI
+ } else {
+ // This is should only happen if there is a problem with the blog itsself.
+ return nil
+ }
+
+ self.init(blog: blog, remoteAPI: remoteAPI, coreDataStack: coreDataStack)
+ }
+
+ init(blog: Blog, remoteAPI: WordPressRestApi, coreDataStack: CoreDataStackSwift) {
+ assert(blog.objectID.persistentStore != nil, "The blog instance should be saved first")
+ self.blog = blog
+ self.coreDataStack = coreDataStack
+ self.remote = BlockEditorSettingsServiceRemote(remoteAPI: remoteAPI)
+ }
+
+ func fetchSettings(_ completion: @escaping BlockEditorSettingsServiceCompletion) {
+ if blog.supports(.blockEditorSettings) {
+ fetchBlockEditorSettings(completion)
+ } else {
+ fetchTheme(completion)
+ }
+ }
+}
+
+// MARK: Editor `theme_supports` support
+private extension BlockEditorSettingsService {
+ func fetchTheme(_ completion: @escaping BlockEditorSettingsServiceCompletion) {
+ remote.fetchTheme(forSiteID: blog.dotComID?.intValue) { [weak self] (response) in
+ guard let `self` = self else { return }
+ switch response {
+ case .success(let editorTheme):
+ self.blog.managedObjectContext?.perform {
+ let originalChecksum = self.blog.blockEditorSettings?.checksum ?? ""
+ self.track(isBlockEditorSettings: false, isFSE: false)
+ self.updateEditorThemeCache(originalChecksum: originalChecksum, editorTheme: editorTheme, completion: completion)
+ }
+ case .failure(let err):
+ DDLogError("Error loading active theme: \(err)")
+ completion(.failure(err))
+ }
+ }
+ }
+
+ func updateEditorThemeCache(originalChecksum: String, editorTheme: RemoteEditorTheme?, completion: @escaping BlockEditorSettingsServiceCompletion) {
+ let newChecksum = editorTheme?.checksum ?? ""
+ guard originalChecksum != newChecksum else {
+ /// The fetched Editor Theme is the same as the cached one so respond with no new changes.
+ let result = SettingsServiceResult(hasChanges: false, blockEditorSettings: self.blog.blockEditorSettings)
+ completion(.success(result))
+ return
+ }
+
+ guard let editorTheme = editorTheme else {
+ /// The original checksum is different than an empty one so we need to clear the old settings.
+ clearCoreData(completion: completion)
+ return
+ }
+
+ /// The fetched Editor Theme is different than the cached one so persist the new one and delete the old one.
+ self.persistEditorThemeToCoreData(blogID: self.blog.objectID, editorTheme: editorTheme) { callback in
+ switch callback {
+ case .success:
+ let result = SettingsServiceResult(hasChanges: true, blockEditorSettings: self.blog.blockEditorSettings)
+ completion(.success(result))
+ case .failure(let err):
+ completion(.failure(err))
+ }
+ }
+ }
+
+ func persistEditorThemeToCoreData(blogID: NSManagedObjectID, editorTheme: RemoteEditorTheme, completion: @escaping (Swift.Result) -> Void) {
+ coreDataStack.performAndSave({ context in
+ guard let blog = context.object(with: blogID) as? Blog else {
+ throw BlockEditorSettingsServiceError.blogNotFound
+ }
+
+ if let blockEditorSettings = blog.blockEditorSettings {
+ // Block Editor Settings nullify on delete
+ context.delete(blockEditorSettings)
+ }
+
+ blog.blockEditorSettings = BlockEditorSettings(editorTheme: editorTheme, context: context)
+ }, completion: completion, on: .main)
+ }
+}
+
+// MARK: Editor Global Styles support
+private extension BlockEditorSettingsService {
+ func fetchBlockEditorSettings(_ completion: @escaping BlockEditorSettingsServiceCompletion) {
+ remote.fetchBlockEditorSettings(forSiteID: blog.dotComID?.intValue) { [weak self] (response) in
+ guard let `self` = self else { return }
+ switch response {
+ case .success(let remoteSettings):
+ self.blog.managedObjectContext?.perform {
+ let originalChecksum = self.blog.blockEditorSettings?.checksum ?? ""
+ self.track(isBlockEditorSettings: true, isFSE: remoteSettings?.isFSETheme ?? false)
+ self.updateBlockEditorSettingsCache(originalChecksum: originalChecksum, remoteSettings: remoteSettings, completion: completion)
+ }
+ case .failure(let err):
+ DDLogError("Error fetching editor settings: \(err)")
+ // The user may not have the gutenberg plugin installed so try /wp/v2/themes to maintain feature support.
+ // In WP 5.9 we may be able to skip this attempt.
+ self.fetchTheme(completion)
+ }
+ }
+ }
+
+ func updateBlockEditorSettingsCache(originalChecksum: String, remoteSettings: RemoteBlockEditorSettings?, completion: @escaping BlockEditorSettingsServiceCompletion) {
+ let newChecksum = remoteSettings?.checksum ?? ""
+ guard originalChecksum != newChecksum else {
+ /// The fetched Block Editor Settings is the same as the cached one so respond with no new changes.
+ let result = SettingsServiceResult(hasChanges: false, blockEditorSettings: self.blog.blockEditorSettings)
+ completion(.success(result))
+ return
+ }
+
+ guard let remoteSettings = remoteSettings else {
+ /// The original checksum is different than an empty one so we need to clear the old settings.
+ clearCoreData(completion: completion)
+ return
+ }
+
+ /// The fetched Block Editor Settings is different than the cached one so persist the new one and delete the old one.
+ self.persistBlockEditorSettingsToCoreData(blogID: self.blog.objectID, remoteSettings: remoteSettings) { callback in
+ switch callback {
+ case .success:
+ let result = SettingsServiceResult(hasChanges: true, blockEditorSettings: self.blog.blockEditorSettings)
+ completion(.success(result))
+ case .failure(let err):
+ completion(.failure(err))
+ }
+ }
+ }
+
+ func persistBlockEditorSettingsToCoreData(blogID: NSManagedObjectID, remoteSettings: RemoteBlockEditorSettings, completion: @escaping (Swift.Result) -> Void) {
+ coreDataStack.performAndSave({ context in
+ guard let blog = context.object(with: blogID) as? Blog else {
+ throw BlockEditorSettingsServiceError.blogNotFound
+ }
+
+ if let blockEditorSettings = blog.blockEditorSettings {
+ // Block Editor Settings nullify on delete
+ context.delete(blockEditorSettings)
+ }
+
+ blog.blockEditorSettings = BlockEditorSettings(remoteSettings: remoteSettings, context: context)
+ }, completion: completion, on: .main)
+ }
+}
+
+// MARK: Shared Events
+private extension BlockEditorSettingsService {
+ func clearCoreData(completion: @escaping BlockEditorSettingsServiceCompletion) {
+ coreDataStack.performAndSave({ context in
+ guard let blogInContext = try? context.existingObject(with: self.blog.objectID) as? Blog else {
+ return
+ }
+ if let blockEditorSettings = blogInContext.blockEditorSettings {
+ // Block Editor Settings nullify on delete
+ context.delete(blockEditorSettings)
+ }
+ }, completion: {
+ let result = SettingsServiceResult(hasChanges: true, blockEditorSettings: nil)
+ completion(.success(result))
+ }, on: .main)
+ }
+
+ func track(isBlockEditorSettings: Bool, isFSE: Bool) {
+ let endpoint = isBlockEditorSettings ? "wp-block-editor" : "theme_supports"
+ let properties: [AnyHashable: Any] = ["endpoint": endpoint,
+ "full_site_editing": "\(isFSE)"]
+ WPAnalytics.track(.gutenbergEditorSettingsFetched, properties: properties)
+ }
+}
diff --git a/WordPress/Classes/Services/BlogJetpackSettingsService.swift b/WordPress/Classes/Services/BlogJetpackSettingsService.swift
index b0a2601d3df2..4771b2994b92 100644
--- a/WordPress/Classes/Services/BlogJetpackSettingsService.swift
+++ b/WordPress/Classes/Services/BlogJetpackSettingsService.swift
@@ -4,10 +4,10 @@ import WordPressKit
struct BlogJetpackSettingsService {
- fileprivate let context: NSManagedObjectContext
+ private let coreDataStack: CoreDataStack
- init(managedObjectContext context: NSManagedObjectContext) {
- self.context = context
+ init(coreDataStack: CoreDataStack) {
+ self.coreDataStack = coreDataStack
}
/// Sync ALL the Jetpack settings for a blog
@@ -18,10 +18,10 @@ struct BlogJetpackSettingsService {
return
}
guard let remoteAPI = blog.wordPressComRestApi(),
- let blogDotComId = blog.dotComID as? Int,
- let blogSettings = blog.settings else {
- success()
- return
+ let blogDotComId = blog.dotComID as? Int
+ else {
+ failure(nil)
+ return
}
var fetchError: Error? = nil
@@ -60,14 +60,15 @@ struct BlogJetpackSettingsService {
failure(fetchError)
return
}
- self.updateJetpackSettings(blogSettings, remoteSettings: remoteJetpackSettings)
- self.updateJetpackMonitorSettings(blogSettings, remoteSettings: remoteJetpackMonitorSettings)
- do {
- try self.context.save()
- success()
- } catch let error as NSError {
- failure(error)
- }
+
+ self.coreDataStack.performAndSave({ context in
+ guard let blogSettings = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else {
+ return
+ }
+
+ self.updateJetpackSettings(blogSettings, remoteSettings: remoteJetpackSettings)
+ self.updateJetpackMonitorSettings(blogSettings, remoteSettings: remoteJetpackMonitorSettings)
+ }, completion: success, on: .main)
})
}
@@ -79,75 +80,67 @@ struct BlogJetpackSettingsService {
return
}
guard let remoteAPI = blog.wordPressComRestApi(),
- let blogDotComId = blog.dotComID as? Int,
- let blogSettings = blog.settings else {
- failure(nil)
- return
+ let blogDotComId = blog.dotComID as? Int
+ else {
+ failure(nil)
+ return
}
let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI)
- remote.getJetpackModulesSettingsForSite(blogDotComId,
- success: { (remoteModulesSettings) in
- self.updateJetpackModulesSettings(blogSettings, remoteSettings: remoteModulesSettings)
- do {
- try self.context.save()
- success()
- } catch let error as NSError {
- failure(error)
- }
- },
- failure: { (error) in
- failure(error)
- })
+ remote.getJetpackModulesSettingsForSite(
+ blogDotComId,
+ success: { (remoteModulesSettings) in
+ self.coreDataStack.performAndSave({ context in
+ guard let blogSettings = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else {
+ return
+ }
+ self.updateJetpackModulesSettings(blogSettings, remoteSettings: remoteModulesSettings)
+ }, completion: success, on: .main)
+ },
+ failure: failure
+ )
}
func updateJetpackSettingsForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) {
guard let remoteAPI = blog.wordPressComRestApi(),
let blogDotComId = blog.dotComID as? Int,
- let blogSettings = blog.settings else {
- failure(nil)
- return
+ let blogSettings = blog.settings
+ else {
+ failure(nil)
+ return
}
+ let changes = blogSettings.changedValues()
let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI)
- remote.updateJetpackSettingsForSite(blogDotComId,
- settings: jetpackSettingsRemote(blogSettings),
- success: {
- do {
- try self.context.save()
- success()
- } catch let error as NSError {
- failure(error)
- }
- },
- failure: { (error) in
- failure(error)
- })
-
+ remote.updateJetpackSettingsForSite(
+ blogDotComId,
+ settings: jetpackSettingsRemote(blogSettings),
+ success: {
+ self.updateSettings(of: blog, withKeyValueChanges: changes, success: success)
+ },
+ failure: failure
+ )
}
func updateJetpackMonitorSettingsForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) {
guard let remoteAPI = blog.wordPressComRestApi(),
let blogDotComId = blog.dotComID as? Int,
- let blogSettings = blog.settings else {
- failure(nil)
- return
+ let blogSettings = blog.settings
+ else {
+ failure(nil)
+ return
}
+ let changes = blogSettings.changedValues()
let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI)
- remote.updateJetpackMonitorSettingsForSite(blogDotComId,
- settings: jetpackMonitorsSettingsRemote(blogSettings),
- success: {
- do {
- try self.context.save()
- success()
- } catch let error as NSError {
- failure(error)
- }
- },
- failure: { (error) in
- failure(error)
- })
+ remote.updateJetpackMonitorSettingsForSite(
+ blogDotComId,
+ settings: jetpackMonitorsSettingsRemote(blogSettings),
+ success: {
+ self.updateSettings(of: blog, withKeyValueChanges: changes, success: success)
+ },
+ failure: failure
+ )
}
func updateJetpackLazyImagesModuleSettingForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) {
@@ -156,15 +149,21 @@ struct BlogJetpackSettingsService {
return
}
- updateJetpackModuleActiveSettingForBlog(blog,
- module: BlogJetpackSettingsServiceRemote.Keys.lazyLoadImages,
- active: blogSettings.jetpackLazyLoadImages,
- success: {
- success()
- },
- failure: { (error) in
- failure(error)
- })
+ let isActive = blogSettings.jetpackLazyLoadImages
+ updateJetpackModuleActiveSettingForBlog(
+ blog,
+ module: BlogJetpackSettingsServiceRemote.Keys.lazyLoadImages,
+ active: isActive,
+ success: {
+ self.coreDataStack.performAndSave({ context in
+ guard let blogSettingsInContext = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else {
+ return
+ }
+ blogSettingsInContext.jetpackLazyLoadImages = isActive
+ }, completion: success, on: .main)
+ },
+ failure: failure
+ )
}
func updateJetpackServeImagesFromOurServersModuleSettingForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) {
@@ -173,15 +172,21 @@ struct BlogJetpackSettingsService {
return
}
- updateJetpackModuleActiveSettingForBlog(blog,
- module: BlogJetpackSettingsServiceRemote.Keys.serveImagesFromOurServers,
- active: blogSettings.jetpackServeImagesFromOurServers,
- success: {
- success()
- },
- failure: { (error) in
- failure(error)
- })
+ let isActive = blogSettings.jetpackServeImagesFromOurServers
+ updateJetpackModuleActiveSettingForBlog(
+ blog,
+ module: BlogJetpackSettingsServiceRemote.Keys.serveImagesFromOurServers,
+ active: isActive,
+ success: {
+ self.coreDataStack.performAndSave({ context in
+ guard let blogSettingsInContext = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else {
+ return
+ }
+ blogSettingsInContext.jetpackServeImagesFromOurServers = isActive
+ }, completion: success, on: .main)
+ },
+ failure: failure
+ )
}
func updateJetpackModuleActiveSettingForBlog(_ blog: Blog, module: String, active: Bool, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) {
@@ -192,20 +197,13 @@ struct BlogJetpackSettingsService {
}
let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI)
- remote.updateJetpackModuleActiveSettingForSite(blogDotComId,
- module: module,
- active: active,
- success: {
- do {
- try self.context.save()
- success()
- } catch let error as NSError {
- failure(error)
- }
- },
- failure: { (error) in
- failure(error)
- })
+ remote.updateJetpackModuleActiveSettingForSite(
+ blogDotComId,
+ module: module,
+ active: active,
+ success: success,
+ failure: failure
+ )
}
func disconnectJetpackFromBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) {
@@ -216,13 +214,7 @@ struct BlogJetpackSettingsService {
}
let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI)
- remote.disconnectJetpackFromSite(blogDotComId,
- success: {
- success()
- },
- failure: { (error) in
- failure(error)
- })
+ remote.disconnectJetpackFromSite(blogDotComId, success: success, failure: failure)
}
}
@@ -232,7 +224,7 @@ private extension BlogJetpackSettingsService {
func updateJetpackSettings(_ settings: BlogSettings, remoteSettings: RemoteBlogJetpackSettings) {
settings.jetpackMonitorEnabled = remoteSettings.monitorEnabled
settings.jetpackBlockMaliciousLoginAttempts = remoteSettings.blockMaliciousLoginAttempts
- settings.jetpackLoginWhiteListedIPAddresses = remoteSettings.loginWhiteListedIPAddresses
+ settings.jetpackLoginAllowListedIPAddresses = remoteSettings.loginAllowListedIPAddresses
settings.jetpackSSOEnabled = remoteSettings.ssoEnabled
settings.jetpackSSOMatchAccountsByEmail = remoteSettings.ssoMatchAccountsByEmail
settings.jetpackSSORequireTwoStepAuthentication = remoteSettings.ssoRequireTwoStepAuthentication
@@ -251,7 +243,7 @@ private extension BlogJetpackSettingsService {
func jetpackSettingsRemote(_ settings: BlogSettings) -> RemoteBlogJetpackSettings {
return RemoteBlogJetpackSettings(monitorEnabled: settings.jetpackMonitorEnabled,
blockMaliciousLoginAttempts: settings.jetpackBlockMaliciousLoginAttempts,
- loginWhiteListedIPAddresses: settings.jetpackLoginWhiteListedIPAddresses ?? Set(),
+ loginAllowListedIPAddresses: settings.jetpackLoginAllowListedIPAddresses ?? Set(),
ssoEnabled: settings.jetpackSSOEnabled,
ssoMatchAccountsByEmail: settings.jetpackSSOMatchAccountsByEmail,
ssoRequireTwoStepAuthentication: settings.jetpackSSORequireTwoStepAuthentication)
@@ -262,4 +254,15 @@ private extension BlogJetpackSettingsService {
monitorPushNotifications: settings.jetpackMonitorPushNotifications)
}
+ func updateSettings(of blog: Blog, withKeyValueChanges changes: [String: Any], success: @escaping () -> Void) {
+ coreDataStack.performAndSave({ context in
+ guard let blogSettingsInContext = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else {
+ return
+ }
+
+ for (key, value) in changes {
+ blogSettingsInContext.setValue(value, forKey: key)
+ }
+ }, completion: success, on: .main)
+ }
}
diff --git a/WordPress/Classes/Services/BlogService+BlogAuthors.swift b/WordPress/Classes/Services/BlogService+BlogAuthors.swift
index 8eb2736b5ef6..941255f4d409 100644
--- a/WordPress/Classes/Services/BlogService+BlogAuthors.swift
+++ b/WordPress/Classes/Services/BlogService+BlogAuthors.swift
@@ -2,14 +2,19 @@ import Foundation
extension BlogService {
- @objc func blogAuthors(for blog: Blog, with remoteUsers: [RemoteUser]) {
+ /// Synchronizes authors for a `Blog` from an array of `RemoteUser`s.
+ /// - Parameters:
+ /// - blog: Blog object.
+ /// - remoteUsers: Array of `RemoteUser`s.
+ @objc(updateBlogAuthorsForBlog:withRemoteUsers:inContext:)
+ func updateBlogAuthors(for blog: Blog, with remoteUsers: [RemoteUser], in context: NSManagedObjectContext) {
do {
- guard let blog = try managedObjectContext.existingObject(with: blog.objectID) as? Blog else {
+ guard let blog = try context.existingObject(with: blog.objectID) as? Blog else {
return
}
remoteUsers.forEach {
- let blogAuthor = findBlogAuthor(with: $0.userID, and: blog)
+ let blogAuthor = findBlogAuthor(with: $0.userID, and: blog, in: context)
blogAuthor.userID = $0.userID
blogAuthor.username = $0.username
blogAuthor.email = $0.email
@@ -17,9 +22,16 @@ extension BlogService {
blogAuthor.primaryBlogID = $0.primaryBlogID
blogAuthor.avatarURL = $0.avatarURL
blogAuthor.linkedUserID = $0.linkedUserID
+ blogAuthor.deletedFromBlog = false
blog.addToAuthors(blogAuthor)
}
+
+ // Local authors who weren't included in the remote users array should be set as deleted.
+ let remoteUserIDs = Set(remoteUsers.map { $0.userID })
+ blog.authors?
+ .filter { !remoteUserIDs.contains($0.userID) }
+ .forEach { $0.deletedFromBlog = true }
} catch {
return
}
@@ -28,8 +40,7 @@ extension BlogService {
private extension BlogService {
- private func findBlogAuthor(with userId: NSNumber, and blog: Blog) -> BlogAuthor {
- return managedObjectContext.entity(of: BlogAuthor.self,
- with: NSPredicate(format: "\(#keyPath(BlogAuthor.userID)) = %@ AND \(#keyPath(BlogAuthor.blog)) = %@", userId, blog))
+ private func findBlogAuthor(with userId: NSNumber, and blog: Blog, in context: NSManagedObjectContext) -> BlogAuthor {
+ return context.entity(of: BlogAuthor.self, with: NSPredicate(format: "\(#keyPath(BlogAuthor.userID)) = %@ AND \(#keyPath(BlogAuthor.blog)) = %@", userId, blog))
}
}
diff --git a/WordPress/Classes/Services/BlogService+BloggingPrompts.swift b/WordPress/Classes/Services/BlogService+BloggingPrompts.swift
new file mode 100644
index 000000000000..1ae65dc678dd
--- /dev/null
+++ b/WordPress/Classes/Services/BlogService+BloggingPrompts.swift
@@ -0,0 +1,22 @@
+import CoreData
+
+extension BlogService {
+
+ @objc func updatePromptSettings(for blog: RemoteBlog?, context: NSManagedObjectContext) {
+ guard let blog = blog,
+ let jsonSettings = blog.options["blogging_prompts_settings"] as? [String: Any],
+ let settingsValue = jsonSettings["value"],
+ let data = try? JSONSerialization.data(withJSONObject: settingsValue),
+ let remoteSettings = try? JSONDecoder().decode(RemoteBloggingPromptsSettings.self, from: data) else {
+ return
+ }
+
+ let fetchRequest = BloggingPromptSettings.fetchRequest()
+ fetchRequest.predicate = NSPredicate(format: "\(#keyPath(BloggingPromptSettings.siteID)) = %@", blog.blogID)
+ fetchRequest.fetchLimit = 1
+ let existingSettings = (try? context.fetch(fetchRequest))?.first
+ let settings = existingSettings ?? BloggingPromptSettings(context: context)
+ settings.configure(with: remoteSettings, siteID: blog.blogID.int32Value, context: context)
+ }
+
+}
diff --git a/WordPress/Classes/Services/BlogService+Deduplicate.swift b/WordPress/Classes/Services/BlogService+Deduplicate.swift
deleted file mode 100644
index 9f3bc775f2d5..000000000000
--- a/WordPress/Classes/Services/BlogService+Deduplicate.swift
+++ /dev/null
@@ -1,61 +0,0 @@
-import Foundation
-
-extension BlogService {
- /// Removes any duplicate blogs in the given account
- ///
- /// We consider a blog to be a duplicate of another if they have the same dotComID.
- /// For each group of duplicate blogs, this will delete all but one, giving preference to
- /// blogs that have local drafts.
- ///
- /// If there's more than one blog in each group with local drafts, those will be reassigned
- /// to the remaining blog.
- ///
- @objc(deduplicateBlogsForAccount:)
- func deduplicateBlogs(for account: WPAccount) {
- // Group all the account blogs by ID so it's easier to find duplicates
- let blogsById = Dictionary(grouping: account.blogs, by: { $0.dotComID?.intValue ?? 0 })
- // For any group with more than one blog, remove duplicates
- for (blogID, group) in blogsById where group.count > 1 {
- assert(blogID > 0, "There should not be a Blog without ID if it has an account")
- guard blogID > 0 else {
- DDLogError("Found one or more WordPress.com blogs without ID, skipping de-duplication")
- continue
- }
- DDLogWarn("Found \(group.count - 1) duplicates for blog with ID \(blogID)")
- deduplicate(group: group)
- }
- }
-
- private func deduplicate(group: [Blog]) {
- // If there's a blog with local drafts, we'll preserve that one, otherwise we pick up the first
- // since we don't really care which blog to pick
- let candidateIndex = group.firstIndex(where: { !localDrafts(for: $0).isEmpty }) ?? 0
- let candidate = group[candidateIndex]
-
- // We look through every other blog
- for (index, blog) in group.enumerated() where index != candidateIndex {
- // If there are other blogs with local drafts, we reassing them to the blog that
- // is not going to be deleted
- for draft in localDrafts(for: blog) {
- DDLogInfo("Migrating local draft \(draft.postTitle ?? "") to de-duplicated blog")
- draft.blog = candidate
- }
- // Once the drafts are moved (if any), we can safely delete the duplicate
- DDLogInfo("Deleting duplicate blog \(blog.logDescription())")
- managedObjectContext.delete(blog)
- }
- }
-
- private func localDrafts(for blog: Blog) -> [AbstractPost] {
- // The original predicate from PostService.countPostsWithoutRemote() was:
- // "postID = NULL OR postID <= 0"
- // Swift optionals make things a bit more verbose, but this should be equivalent
- return blog.posts?.filter({ (post) -> Bool in
- if let postID = post.postID?.intValue,
- postID > 0 {
- return false
- }
- return true
- }) ?? []
- }
-}
diff --git a/WordPress/Classes/Services/BlogService+Domains.swift b/WordPress/Classes/Services/BlogService+Domains.swift
new file mode 100644
index 000000000000..cc1039a90799
--- /dev/null
+++ b/WordPress/Classes/Services/BlogService+Domains.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+/// This extension is necessary because DomainsService is unavailable in ObjC.
+///
+extension BlogService {
+ enum BlogServiceDomainError: Error {
+ case noAccountForSpecifiedBlog(blog: Blog)
+ case noSiteIDForSpecifiedBlog(blog: Blog)
+ case noWordPressComRestApi(blog: Blog)
+ }
+
+ /// Convenience method to be able to refresh the blogs from ObjC.
+ ///
+ @objc
+ func refreshDomains(for blog: Blog, success: (() -> Void)?, failure: ((Error) -> Void)?) {
+ guard let account = blog.account else {
+ failure?(BlogServiceDomainError.noAccountForSpecifiedBlog(blog: blog))
+ return
+ }
+
+ guard account.wordPressComRestApi != nil else {
+ failure?(BlogServiceDomainError.noWordPressComRestApi(blog: blog))
+ return
+ }
+
+ guard let siteID = blog.dotComID?.intValue else {
+ failure?(BlogServiceDomainError.noSiteIDForSpecifiedBlog(blog: blog))
+ return
+ }
+
+ let service = DomainsService(coreDataStack: coreDataStack, account: account)
+
+ service.refreshDomains(siteID: siteID) { result in
+ switch result {
+ case .success:
+ success?()
+ case .failure(let error):
+ failure?(error)
+ }
+ }
+ }
+}
diff --git a/WordPress/Classes/Services/BlogService+JetpackConvenience.swift b/WordPress/Classes/Services/BlogService+JetpackConvenience.swift
index b71ec40397d5..e1956f5e33c5 100644
--- a/WordPress/Classes/Services/BlogService+JetpackConvenience.swift
+++ b/WordPress/Classes/Services/BlogService+JetpackConvenience.swift
@@ -1,6 +1,13 @@
extension BlogService {
static func blog(with site: JetpackSiteRef, context: NSManagedObjectContext = ContextManager.shared.mainContext) -> Blog? {
- let service = BlogService(managedObjectContext: context)
- return service.blog(byBlogId: site.siteID as NSNumber, andUsername: site.username)
+ let blog: Blog?
+
+ if site.isSelfHostedWithoutJetpack, let xmlRPC = site.xmlRPC {
+ blog = Blog.lookup(username: site.username, xmlrpc: xmlRPC, in: context)
+ } else {
+ blog = try? BlogQuery().blogID(site.siteID).dotComAccountUsername(site.username).blog(in: context)
+ }
+
+ return blog
}
}
diff --git a/WordPress/Classes/Services/BlogService+Reminders.swift b/WordPress/Classes/Services/BlogService+Reminders.swift
new file mode 100644
index 000000000000..c3bd08396c4b
--- /dev/null
+++ b/WordPress/Classes/Services/BlogService+Reminders.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+extension BlogService {
+ @objc func unscheduleBloggingReminders(for blog: Blog) {
+ do {
+ let scheduler = try ReminderScheduleCoordinator()
+ scheduler.schedule(.none, for: blog, completion: { _ in })
+ // We're currently not propagating success / failure here, as it's
+ // it's only used when removing blogs or accounts, and there's
+ // no extra action we can take if it fails anyway.
+ } catch {
+ DDLogError("Could not instantiate the reminders scheduler: \(error.localizedDescription)")
+ }
+ }
+}
diff --git a/WordPress/Classes/Services/BlogService.h b/WordPress/Classes/Services/BlogService.h
index 287f91837159..87856ed2c947 100644
--- a/WordPress/Classes/Services/BlogService.h
+++ b/WordPress/Classes/Services/BlogService.h
@@ -1,64 +1,17 @@
#import
-#import "LocalCoreDataService.h"
+#import "CoreDataService.h"
#import "Blog.h"
NS_ASSUME_NONNULL_BEGIN
extern NSString *const WordPressMinimumVersion;
extern NSString *const WPBlogUpdatedNotification;
+extern NSString *const WPBlogSettingsUpdatedNotification;
@class WPAccount;
@class SiteInfo;
-@interface BlogService : LocalCoreDataService
-
-+ (instancetype)serviceWithMainContext;
-
-- (instancetype) init __attribute__((unavailable("must use initWithManagedObjectContext")));
-
-/**
- Returns the blog that matches with a given blogID
- */
-- (nullable Blog *)blogByBlogId:(NSNumber *)blogID;
-
-/**
- Returns the blog that matches with a given blogID and account.username
- */
-- (nullable Blog *)blogByBlogId:(NSNumber *)blogID andUsername:(NSString *)username;
-
-/**
- Returns the blog that matches with a given hostname
- */
-- (nullable Blog *)blogByHostname:(NSString *)hostname;
-
-/**
- Returns the blog currently flagged as the one last used, or the primary blog,
- or the first blog in an alphanumerically sorted list, whichever is found first.
- */
-- (nullable Blog *)lastUsedOrFirstBlog;
-
-/**
- Returns the blog currently flagged as the one last used, or the primary blog,
- or the first blog in an alphanumerically sorted list that supports the given
- feature, whichever is found first.
- */
-- (nullable Blog *)lastUsedOrFirstBlogThatSupports:(BlogFeature)feature;
-
-/**
- Returns the blog currently flaged as the one last used.
- */
-- (nullable Blog *)lastUsedBlog;
-
-/**
- Returns the first blog in an alphanumerically sorted list.
- */
-- (nullable Blog *)firstBlog;
-
-/**
- Returns the default WPCom blog.
- */
-- (nullable Blog *)primaryBlog;
-
+@interface BlogService : CoreDataService
/**
* Sync all available blogs for an acccount
@@ -127,6 +80,17 @@ extern NSString *const WPBlogUpdatedNotification;
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure;
+/**
+ * Sync authors from the server
+ *
+ * @param blog the blog from where to read the information from
+ * @param success a block that is invoked when the sync is successful
+ * @param failure a block that in invoked when the sync fails.
+ */
+- (void)syncAuthorsForBlog:(Blog *)blog
+ success:(void (^)(void))success
+ failure:(void (^)(NSError *error))failure;
+
/**
* Update blog settings to the server
*
@@ -149,94 +113,8 @@ extern NSString *const WPBlogUpdatedNotification;
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure;
-/**
- * Update the password for the blog.
- *
- * @discussion This is only valid for self-hosted sites that don't use jetpack.
- *
- * @param password the new password to use for the blog
- * @param blog to change the password.
- */
-- (void)updatePassword:(NSString *)password forBlog:(Blog *)blog;
-
-- (BOOL)hasVisibleWPComAccounts;
-
-- (BOOL)hasAnyJetpackBlogs;
-
-- (NSInteger)blogCountForAllAccounts;
-
-- (NSInteger)blogCountSelfHosted;
-
-- (NSInteger)blogCountForWPComAccounts;
-
-- (NSInteger)blogCountVisibleForWPComAccounts;
-
-- (NSInteger)blogCountVisibleForAllAccounts;
-
-- (NSArray *)blogsForAllAccounts;
-
-- (NSArray *)visibleBlogsForWPComAccounts;
-
-- (NSArray *)blogsWithNoAccount;
-
-- (NSArray *)blogsWithPredicate:(NSPredicate *)predicate;
-
-/**
- Returns every stored blog, arranged in a Dictionary by blogId.
- */
-- (NSDictionary *)blogsForAllAccountsById;
-
-/*! Determine timezone for blog from blog options. If no timezone information is stored on
- * the device, then assume GMT+0 is the default.
- *
- * \param blog The blog/site to determine the timezone for.
- */
-- (NSTimeZone *)timeZoneForBlog:(Blog *)blog;
-
- (void)removeBlog:(Blog *)blog;
-///--------------------
-/// @name Blog creation
-///--------------------
-
-/**
- Searches for a `Blog` object for this account with the given XML-RPC endpoint
-
- @warn If more than one blog is found, they'll be considered duplicates and be
- deleted leaving only one of them.
-
- @param xmlrpc the XML-RPC endpoint URL as a string
- @param account the account the blog belongs to
- @return the blog if one was found, otherwise it returns nil
- */
-- (nullable Blog *)findBlogWithXmlrpc:(NSString *)xmlrpc
- inAccount:(WPAccount *)account;
-
-/**
- Searches for a `Blog` object for this account with the given username
-
- @param xmlrpc the XML-RPC endpoint URL as a string
- @param username the blog's username
- @return the blog if one was found, otherwise it returns nil
- */
-- (nullable Blog *)findBlogWithXmlrpc:(NSString *)xmlrpc
- andUsername:(NSString *)username;
-
-/**
- Creates a blank `Blog` object for this account
-
- @param account the account the blog belongs to
- @return the newly created blog
- */
-- (Blog *)createBlogWithAccount:(WPAccount *)account;
-
-/**
- Creates a blank `Blog` object with no account
-
- @return the newly created blog
- */
-- (Blog *)createBlog;
-
@end
NS_ASSUME_NONNULL_END
diff --git a/WordPress/Classes/Services/BlogService.m b/WordPress/Classes/Services/BlogService.m
index 775fc8e093f3..b5a2e82ea186 100644
--- a/WordPress/Classes/Services/BlogService.m
+++ b/WordPress/Classes/Services/BlogService.m
@@ -2,130 +2,29 @@
#import "Blog.h"
#import "WPAccount.h"
#import "AccountService.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "WPError.h"
-#import "Comment.h"
#import "Media.h"
#import "PostCategoryService.h"
#import "CommentService.h"
#import "PostService.h"
#import "TodayExtensionService.h"
-#import "ContextManager.h"
#import "WordPress-Swift.h"
#import "PostType.h"
@import WordPressKit;
@import WordPressShared;
+@class Comment;
+
NSString *const WPComGetFeatures = @"wpcom.getFeatures";
NSString *const VideopressEnabled = @"videopress_enabled";
NSString *const WordPressMinimumVersion = @"4.0";
NSString *const HttpsPrefix = @"https://";
NSString *const WPBlogUpdatedNotification = @"WPBlogUpdatedNotification";
-
-CGFloat const OneHourInSeconds = 60.0 * 60.0;
-
+NSString *const WPBlogSettingsUpdatedNotification = @"WPBlogSettingsUpdatedNotification";
@implementation BlogService
-+ (instancetype)serviceWithMainContext
-{
- NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext];
- return [[BlogService alloc] initWithManagedObjectContext:context];
-}
-
-- (Blog *)blogByBlogId:(NSNumber *)blogID
-{
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"blogID == %@", blogID];
- return [self blogWithPredicate:predicate];
-}
-
-- (Blog *)blogByBlogId:(NSNumber *)blogID andUsername:(NSString *)username
-{
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"blogID = %@ AND account.username = %@", blogID, username];
- return [self blogWithPredicate:predicate];
-}
-
-- (Blog *)blogByHostname:(NSString *)hostname
-{
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"url CONTAINS %@", hostname];
- NSArray * blogs = [self blogsWithPredicate:predicate];
- return [blogs firstObject];
-}
-
-- (Blog *)lastUsedOrFirstBlog
-{
- Blog *blog = [self lastUsedOrPrimaryBlog];
-
- if (!blog) {
- blog = [self firstBlog];
- }
-
- return blog;
-}
-
-- (Blog *)lastUsedOrFirstBlogThatSupports:(BlogFeature)feature
-{
- Blog *blog = [self lastUsedOrPrimaryBlog];
-
- if (![blog supports:feature]) {
- blog = [self firstBlogThatSupports:feature];
- }
-
- return blog;
-}
-
-- (Blog *)lastUsedOrPrimaryBlog
-{
- Blog *blog = [self lastUsedBlog];
-
- if (!blog) {
- blog = [self primaryBlog];
- }
-
- return blog;
-}
-
-- (Blog *)lastUsedBlog
-{
- // Try to get the last used blog, if there is one.
- RecentSitesService *recentSitesService = [RecentSitesService new];
- NSString *url = [[recentSitesService recentSites] firstObject];
- if (!url) {
- return nil;
- }
-
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"visible = YES AND url = %@", url];
- Blog *blog = [self blogWithPredicate:predicate];
-
- return blog;
-}
-
-- (Blog *)primaryBlog
-{
- AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext];
- WPAccount *defaultAccount = [accountService defaultWordPressComAccount];
- return defaultAccount.defaultBlog;
-}
-
-- (Blog *)firstBlogThatSupports:(BlogFeature)feature
-{
- NSPredicate *predicate = [self predicateForVisibleBlogs];
- NSArray *results = [self blogsWithPredicate:predicate];
-
- for (Blog *blog in results) {
- if ([blog supports:feature]) {
- return blog;
- }
- }
- return nil;
-}
-
-- (Blog *)firstBlog
-{
- NSPredicate *predicate = [self predicateForVisibleBlogs];
- return [self blogWithPredicate:predicate];
-}
-
- (void)syncBlogsForAccount:(WPAccount *)account
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
@@ -133,27 +32,14 @@ - (void)syncBlogsForAccount:(WPAccount *)account
DDLogMethod();
id remote = [self remoteForAccount:account];
- [remote getBlogsWithSuccess:^(NSArray *blogs) {
- [self.managedObjectContext performBlock:^{
-
- // Let's check if the account object is not nil. Otherwise we'll get an exception below.
- NSManagedObjectID *accountObjectID = account.objectID;
- if (!accountObjectID) {
- DDLogError(@"Error: The Account objectID could not be loaded");
- return;
- }
-
- // Reload the Account in the current Context
- NSError *error = nil;
- WPAccount *accountInContext = (WPAccount *)[self.managedObjectContext existingObjectWithID:accountObjectID
- error:&error];
- if (!accountInContext) {
- DDLogError(@"Error loading WordPress Account: %@", error);
- return;
- }
-
- [self mergeBlogs:blogs withAccount:accountInContext completion:success];
+
+ BOOL filterJetpackSites = [AppConfiguration showJetpackSitesOnly];
+ [remote getBlogs:filterJetpackSites success:^(NSArray *blogs) {
+ [[[JetpackCapabilitiesService alloc] init] syncWithBlogs:blogs success:^(NSArray *blogs) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ [self mergeBlogs:blogs withAccountID:account.objectID inContext:context];
+ } completion:success onQueue:dispatch_get_main_queue()];
}];
} failure:^(NSError *error) {
DDLogError(@"Error syncing blogs: %@", error);
@@ -218,16 +104,14 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co
dispatch_group_enter(syncGroup);
[restRemote syncBlogSettingsWithSuccess:^(RemoteBlogSettings *settings) {
- [self.managedObjectContext performBlock:^{
- NSError *error = nil;
- Blog *blogInContext = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID
- error:&error];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blogInContext = (Blog *)[context existingObjectWithID:blogObjectID error:nil];
if (blogInContext) {
[self updateSettings:blogInContext.settings withRemoteSettings:settings];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
}
+ } completion:^{
dispatch_group_leave(syncGroup);
- }];
+ } onQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)];
} failure:^(NSError *error) {
DDLogError(@"Failed syncing settings for blog %@: %@", blog.url, error);
dispatch_group_leave(syncGroup);
@@ -244,7 +128,7 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co
dispatch_group_leave(syncGroup);
}];
- PostCategoryService *categoryService = [[PostCategoryService alloc] initWithManagedObjectContext:self.managedObjectContext];
+ PostCategoryService *categoryService = [[PostCategoryService alloc] initWithCoreDataStack:self.coreDataStack];
dispatch_group_enter(syncGroup);
[categoryService syncCategoriesForBlog:blog
success:^{
@@ -255,7 +139,7 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co
dispatch_group_leave(syncGroup);
}];
- SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:self.managedObjectContext];
+ SharingSyncService *sharingService = [[SharingSyncService alloc] initWithCoreDataStack:self.coreDataStack];
dispatch_group_enter(syncGroup);
[sharingService syncPublicizeConnectionsForBlog:blog
success:^{
@@ -271,11 +155,11 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co
[self updateMultiAuthor:users forBlog:blogObjectID];
dispatch_group_leave(syncGroup);
} failure:^(NSError *error) {
- DDLogError(@"Failed checking muti-author status for blog %@: %@", blog.url, error);
+ DDLogError(@"Failed checking multi-author status for blog %@: %@", blog.url, error);
dispatch_group_leave(syncGroup);
}];
- PlanService *planService = [[PlanService alloc] initWithManagedObjectContext:self.managedObjectContext];
+ PlanService *planService = [[PlanService alloc] initWithCoreDataStack:self.coreDataStack];
dispatch_group_enter(syncGroup);
[planService getWpcomPlans:blog.account
success:^{
@@ -293,16 +177,31 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co
dispatch_group_leave(syncGroup);
}];
- EditorSettingsService *editorService = [[EditorSettingsService alloc] initWithManagedObjectContext:self.managedObjectContext];
+ EditorSettingsService *editorService = [[EditorSettingsService alloc] initWithCoreDataStack:self.coreDataStack];
dispatch_group_enter(syncGroup);
[editorService syncEditorSettingsForBlog:blog success:^{
dispatch_group_leave(syncGroup);
- } failure:^(NSError * _Nonnull error) {
+ } failure:^(NSError * _Nonnull __unused error) {
DDLogError(@"Failed to sync Editor settings");
dispatch_group_leave(syncGroup);
}];
-
-
+
+ BlazeService *blazeService = [BlazeService createService];
+ dispatch_group_enter(syncGroup);
+ [blazeService getStatusFor:blog completion:^{
+ dispatch_group_leave(syncGroup);
+ }];
+
+ if ([DomainsDashboardCardHelper isFeatureEnabled]) {
+ dispatch_group_enter(syncGroup);
+ [self refreshDomainsFor:blog success:^{
+ dispatch_group_leave(syncGroup);
+ } failure:^(NSError * _Nonnull error) {
+ DDLogError(@"Failed refreshing domains: %@", error);
+ dispatch_group_leave(syncGroup);
+ }];
+ }
+
// When everything has left the syncGroup (all calls have ended with success
// or failure) perform the completionHandler
dispatch_group_notify(syncGroup, dispatch_get_main_queue(),^{
@@ -317,23 +216,20 @@ - (void)syncSettingsForBlog:(Blog *)blog
failure:(void (^)(NSError *error))failure
{
NSManagedObjectID *blogID = [blog objectID];
- [self.managedObjectContext performBlock:^{
- Blog *blogInContext = (Blog *)[self.managedObjectContext objectWithID:blogID];
+ [self.coreDataStack.mainContext performBlock:^{
+ Blog *blogInContext = (Blog *)[self.coreDataStack.mainContext objectWithID:blogID];
if (!blogInContext) {
if (success) {
success();
}
return;
}
+
void(^updateOnSuccess)(RemoteBlogSettings *) = ^(RemoteBlogSettings *remoteSettings) {
- [self.managedObjectContext performBlock:^{
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blogInContext = (Blog *)[context objectWithID:blogID];
[self updateSettings:blogInContext.settings withRemoteSettings:remoteSettings];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{
- if (success) {
- success();
- }
- }];
- }];
+ } completion:nil onQueue:dispatch_get_main_queue()];
};
id remote = [self remoteForBlog:blogInContext];
if ([remote isKindOfClass:[BlogServiceRemoteXMLRPC class]]) {
@@ -354,49 +250,63 @@ - (void)syncSettingsForBlog:(Blog *)blog
}];
}
+- (void)syncAuthorsForBlog:(Blog *)blog
+ success:(void (^)(void))success
+ failure:(void (^)(NSError *error))failure
+{
+ NSManagedObjectID *blogObjectID = blog.objectID;
+ id remote = [self remoteForBlog:blog];
+
+ [remote getAllAuthorsWithSuccess:^(NSArray *users) {
+ [self updateMultiAuthor:users forBlog:blogObjectID];
+ success();
+ } failure:^(NSError *error) {
+ DDLogError(@"Failed checking multi-author status for blog %@: %@", blog.url, error);
+ failure(error);
+ }];
+}
+
- (void)updateSettingsForBlog:(Blog *)blog
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
{
NSManagedObjectID *blogID = [blog objectID];
- [self.managedObjectContext performBlock:^{
- Blog *blogInContext = (Blog *)[self.managedObjectContext objectWithID:blogID];
+ NSManagedObjectContext *context = self.coreDataStack.mainContext;
+ [context performBlock:^{
+ Blog *blogInContext = (Blog *)[context objectWithID:blogID];
id remote = [self remoteForBlog:blogInContext];
+ RemoteBlogSettings *remoteSettings = [self remoteSettingFromSettings:blogInContext.settings];
- void(^saveOnSuccess)(void) = ^() {
- [self.managedObjectContext performBlock:^{
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{
- if (success) {
- success();
- }
- }];
- }];
+ void(^onSuccess)(void) = ^() {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blogInContext = (Blog *)[context existingObjectWithID:blogID error:nil];
+ if (blogInContext) {
+ [self updateSettings:blogInContext.settings withRemoteSettings:remoteSettings];
+ }
+ } completion:^{
+ if (success) {
+ success();
+ }
+ } onQueue:dispatch_get_main_queue()];
};
if ([remote isKindOfClass:[BlogServiceRemoteXMLRPC class]]) {
BlogServiceRemoteXMLRPC *xmlrpcRemote = remote;
- RemoteBlogSettings *remoteSettings = [self remoteSettingFromSettings:blogInContext.settings];
[xmlrpcRemote updateBlogOptionsWith:[RemoteBlogOptionsHelper remoteOptionsForUpdatingBlogTitleAndTagline:remoteSettings]
- success:saveOnSuccess
+ success:onSuccess
failure:failure];
} else if([remote isKindOfClass:[BlogServiceRemoteREST class]]) {
BlogServiceRemoteREST *restRemote = remote;
- [restRemote updateBlogSettings:[self remoteSettingFromSettings:blogInContext.settings]
- success:saveOnSuccess
+ [restRemote updateBlogSettings:remoteSettings
+ success:onSuccess
failure:failure];
}
}];
}
-- (void)updatePassword:(NSString *)password forBlog:(Blog *)blog
-{
- blog.password = password;
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
-}
-
- (void)syncPostTypesForBlog:(Blog *)blog
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
@@ -404,23 +314,24 @@ - (void)syncPostTypesForBlog:(Blog *)blog
NSManagedObjectID *blogObjectID = blog.objectID;
id remote = [self remoteForBlog:blog];
[remote syncPostTypesWithSuccess:^(NSArray *remotePostTypes) {
- [self.managedObjectContext performBlock:^{
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
NSError *blogError;
- Blog *blogInContext = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID
- error:&blogError];
+ Blog *blogInContext = (Blog *)[context existingObjectWithID:blogObjectID error:&blogError];
if (!blogInContext || blogError) {
DDLogError(@"Error occurred fetching blog in context with: %@", blogError);
- if (failure) {
- failure(blogError);
- return;
- }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (failure) {
+ failure(blogError);
+ return;
+ }
+ });
}
// Create new PostType entities with the RemotePostType objects.
NSMutableSet *postTypes = [NSMutableSet setWithCapacity:remotePostTypes.count];
NSString *entityName = NSStringFromClass([PostType class]);
for (RemotePostType *remoteType in remotePostTypes) {
PostType *postType = [NSEntityDescription insertNewObjectForEntityForName:entityName
- inManagedObjectContext:self.managedObjectContext];
+ inManagedObjectContext:context];
postType.name = remoteType.name;
postType.label = remoteType.label;
postType.apiQueryable = remoteType.apiQueryable;
@@ -428,11 +339,7 @@ - (void)syncPostTypesForBlog:(Blog *)blog
}
// Replace the current set of postTypes with new entities.
blogInContext.postTypes = [NSSet setWithSet:postTypes];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
- if (success) {
- success();
- }
- }];
+ } completion:success onQueue:dispatch_get_main_queue()];
} failure:failure];
}
@@ -446,87 +353,6 @@ - (void)syncPostFormatsForBlog:(Blog *)blog
failure:failure];
}
-- (BOOL)hasVisibleWPComAccounts
-{
- return [self blogCountVisibleForWPComAccounts] > 0;
-}
-
-- (BOOL)hasAnyJetpackBlogs
-{
- NSPredicate *jetpackManagedPredicate = [NSPredicate predicateWithFormat:@"account != NULL AND isHostedAtWPcom = NO"];
- NSInteger jetpackManagedCount = [self blogCountWithPredicate:jetpackManagedPredicate];
- if (jetpackManagedCount > 0) {
- return YES;
- }
-
- NSArray *selfHostedBlogs = [self blogsWithNoAccount];
- NSArray *jetpackUnmanagedBlogs = [selfHostedBlogs wp_filter:^BOOL(Blog *blog) {
- return blog.jetpack.isConnected;
- }];
-
- return [jetpackUnmanagedBlogs count] > 0;
-}
-
-- (NSInteger)blogCountForAllAccounts
-{
- return [self blogCountWithPredicate:nil];
-}
-
-- (NSInteger)blogCountSelfHosted
-{
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"account = NULL"];
- return [self blogCountWithPredicate:predicate];
-}
-
-- (NSInteger)blogCountForWPComAccounts
-{
- return [self blogCountWithPredicate:[NSPredicate predicateWithFormat:@"account != NULL"]];
-}
-
-- (NSInteger)blogCountVisibleForWPComAccounts
-{
- NSPredicate *predicate = [self predicateForVisibleBlogsWPComAccounts];
- return [self blogCountWithPredicate:predicate];
-}
-
-- (NSInteger)blogCountVisibleForAllAccounts
-{
- NSPredicate *predicate = [self predicateForVisibleBlogs];
- return [self blogCountWithPredicate:predicate];
-}
-
-- (NSArray *)blogsWithNoAccount
-{
- NSPredicate *predicate = [self predicateForNoAccount];
- return [self blogsWithPredicate:predicate];
-}
-
-- (NSArray *)blogsForAllAccounts
-{
- return [self blogsWithPredicate:nil];
-}
-
-- (NSArray *)visibleBlogsForWPComAccounts
-{
- NSPredicate *predicate = [self predicateForVisibleBlogsWPComAccounts];
- return [self blogsWithPredicate:predicate];
-}
-
-- (NSDictionary *)blogsForAllAccountsById
-{
- NSMutableDictionary *blogMap = [NSMutableDictionary dictionary];
- NSArray *allBlogs = [self blogsWithPredicate:nil];
-
- for (Blog *blog in allBlogs) {
- if (blog.dotComID != nil) {
- blogMap[blog.dotComID] = blog;
- }
- }
-
- return blogMap;
-}
-
-
///--------------------
/// @name Blog creation
///--------------------
@@ -538,83 +364,24 @@ - (Blog *)findBlogWithDotComID:(NSNumber *)dotComID
return [[account.blogs filteredSetUsingPredicate:predicate] anyObject];
}
-- (Blog *)findBlogWithXmlrpc:(NSString *)xmlrpc
- inAccount:(WPAccount *)account
-{
- NSSet *foundBlogs = [account.blogs filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"xmlrpc like %@", xmlrpc]];
- if ([foundBlogs count] == 1) {
- return [foundBlogs anyObject];
- }
-
- // If more than one blog matches, return the first and delete the rest
- if ([foundBlogs count] > 1) {
- Blog *blogToReturn = [foundBlogs anyObject];
- for (Blog *b in foundBlogs) {
- // Choose blogs with URL not starting with https to account for a glitch in the API in early 2014
- if (!([b.url hasPrefix:HttpsPrefix])) {
- blogToReturn = b;
- break;
- }
- }
-
- for (Blog *b in foundBlogs) {
- if (!([b isEqual:blogToReturn])) {
- [self.managedObjectContext deleteObject:b];
- }
- }
-
- return blogToReturn;
- }
- return nil;
-}
-
-- (Blog *)findBlogWithXmlrpc:(NSString *)xmlrpc
- andUsername:(NSString *)username
-{
- NSArray *foundBlogs = [self blogsWithPredicate:[NSPredicate predicateWithFormat:@"xmlrpc = %@ AND username = %@", xmlrpc, username]];
- return [foundBlogs firstObject];
-}
-
-- (Blog *)createBlogWithAccount:(WPAccount *)account
-{
- Blog *blog = [self createBlog];
- blog.account = account;
- return blog;
-}
-
-- (Blog *)createBlog
-{
- NSString *entityName = NSStringFromClass([Blog class]);
- Blog *blog = [NSEntityDescription insertNewObjectForEntityForName:entityName
- inManagedObjectContext:self.managedObjectContext];
- blog.settings = [self createSettingsWithBlog:blog];
- return blog;
-}
-
-- (BlogSettings *)createSettingsWithBlog:(Blog *)blog
-{
- NSString *entityName = [BlogSettings classNameWithoutNamespaces];
- BlogSettings *settings = [NSEntityDescription insertNewObjectForEntityForName:entityName
- inManagedObjectContext:self.managedObjectContext];
- settings.blog = blog;
- return settings;
-}
-
- (void)removeBlog:(Blog *)blog
{
DDLogInfo(@" remove", blog.hostURL);
[blog.xmlrpcApi invalidateAndCancelTasks];
+ [self unscheduleBloggingRemindersFor:blog];
+
WPAccount *account = blog.account;
- [self.managedObjectContext deleteObject:blog];
- [self.managedObjectContext processPendingChanges];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blogInContext = [context existingObjectWithID:blog.objectID error:nil];
+ [context deleteObject:blogInContext];
+ }];
- AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext];
if (account) {
+ AccountService *accountService = [[AccountService alloc] initWithCoreDataStack:self.coreDataStack];
[accountService purgeAccountIfUnused:account];
}
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
[WPAnalytics refreshMetadata];
}
@@ -623,37 +390,37 @@ - (void)associateSyncedBlogsToJetpackAccount:(WPAccount *)account
failure:(void (^)(NSError *error))failure
{
AccountServiceRemoteREST *remote = [[AccountServiceRemoteREST alloc] initWithWordPressComRestApi:account.wordPressComRestApi];
- [remote getBlogsWithSuccess:^(NSArray *remoteBlogs) {
-
- NSMutableSet *accountBlogIDs = [NSMutableSet new];
- for (RemoteBlog *remoteBlog in remoteBlogs) {
- [accountBlogIDs addObject:remoteBlog.blogID];
- }
-
- NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([Blog class])];
- request.predicate = [NSPredicate predicateWithFormat:@"account = NULL"];
- NSArray *blogs = [self.managedObjectContext executeFetchRequest:request error:nil];
- blogs = [blogs filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary *bindings) {
- Blog *blog = (Blog *)evaluatedObject;
- NSNumber *jetpackBlogID = blog.jetpack.siteID;
- return jetpackBlogID && [accountBlogIDs containsObject:jetpackBlogID];
- }]];
- [account addBlogs:[NSSet setWithArray:blogs]];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
-
- success();
-
- } failure:^(NSError *error) {
- failure(error);
+
+ BOOL filterJetpackSites = [AppConfiguration showJetpackSitesOnly];
+
+ [remote getBlogs:filterJetpackSites success:^(NSArray *remoteBlogs) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ NSMutableSet *accountBlogIDs = [NSMutableSet new];
+ for (RemoteBlog *remoteBlog in remoteBlogs) {
+ [accountBlogIDs addObject:remoteBlog.blogID];
+ }
- }];
+ NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([Blog class])];
+ request.predicate = [NSPredicate predicateWithFormat:@"account = NULL"];
+ NSArray *blogs = [context executeFetchRequest:request error:nil];
+ blogs = [blogs filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary * __unused bindings) {
+ Blog *blog = (Blog *)evaluatedObject;
+ NSNumber *jetpackBlogID = blog.jetpack.siteID;
+ return jetpackBlogID && [accountBlogIDs containsObject:jetpackBlogID];
+ }]];
+
+ WPAccount *accountInContext = [context existingObjectWithID:account.objectID error:nil];
+ [accountInContext addBlogs:[NSSet setWithArray:blogs]];
+ } completion:success onQueue:dispatch_get_main_queue()];
+ } failure:failure];
}
#pragma mark - Private methods
-- (void)mergeBlogs:(NSArray *)blogs withAccount:(WPAccount *)account completion:(void (^)(void))completion
+- (void)mergeBlogs:(NSArray *)blogs withAccountID:(NSManagedObjectID *)accountID inContext:(NSManagedObjectContext *)context
{
// Nuke dead blogs
+ WPAccount *account = [context existingObjectWithID:accountID error:nil];
NSSet *remoteSet = [NSSet setWithArray:[blogs valueForKey:@"blogID"]];
NSSet *localSet = [account.blogs valueForKey:@"dotComID"];
NSMutableSet *toDelete = [localSet mutableCopy];
@@ -662,7 +429,10 @@ - (void)mergeBlogs:(NSArray *)blogs withAccount:(WPAccount *)accou
if ([toDelete count] > 0) {
for (Blog *blog in account.blogs) {
if ([toDelete containsObject:blog.dotComID]) {
- [self.managedObjectContext deleteObject:blog];
+ [self unscheduleBloggingRemindersFor:blog];
+ // Consider switching this to a call to removeBlog in the future
+ // to consolidate behaviour @frosty
+ [context deleteObject:blog];
}
}
}
@@ -670,7 +440,8 @@ - (void)mergeBlogs:(NSArray *)blogs withAccount:(WPAccount *)accou
// Go through each remote incoming blog and make sure we're up to date with titles, etc.
// Also adds any blogs we don't have
for (RemoteBlog *remoteBlog in blogs) {
- [self updateBlogWithRemoteBlog:remoteBlog account:account];
+ [self updateBlogWithRemoteBlog:remoteBlog account:account inContext:context];
+ [self updatePromptSettingsFor:remoteBlog context:context];
}
/*
@@ -682,30 +453,28 @@ - (void)mergeBlogs:(NSArray *)blogs withAccount:(WPAccount *)accou
More context here:
https://github.com/wordpress-mobile/WordPress-iOS/issues/7886#issuecomment-524221031
*/
- [self deduplicateBlogsForAccount:account];
-
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
+ [account deduplicateBlogs];
// Ensure that the account has a default blog defined (if there is one).
- AccountService *service = [[AccountService alloc]initWithManagedObjectContext:self.managedObjectContext];
- [service updateDefaultBlogIfNeeded:account];
-
- if (completion != nil) {
- dispatch_async(dispatch_get_main_queue(), completion);
- }
+ AccountService *service = [[AccountService alloc] initWithCoreDataStack:self.coreDataStack];
+ [service updateDefaultBlogIfNeeded:account inContext:context];
}
-- (void)updateBlogWithRemoteBlog:(RemoteBlog *)remoteBlog account:(WPAccount *)account
+- (void)updateBlogWithRemoteBlog:(RemoteBlog *)remoteBlog account:(WPAccount *)account inContext:(NSManagedObjectContext *)context
{
Blog *blog = [self findBlogWithDotComID:remoteBlog.blogID inAccount:account];
if (!blog && remoteBlog.jetpack) {
- blog = [self migrateRemoteJetpackBlog:remoteBlog forAccount:account];
+ blog = [self migrateRemoteJetpackBlog:remoteBlog forAccount:account inContext:context];
}
if (!blog) {
DDLogInfo(@"New blog from account %@: %@", account.username, remoteBlog);
- blog = [self createBlogWithAccount:account];
+ if (account != nil) {
+ blog = [Blog createBlankBlogWithAccount:account];
+ } else {
+ blog = [Blog createBlankBlogInContext:context];
+ }
blog.xmlrpc = remoteBlog.xmlrpc;
}
@@ -714,12 +483,11 @@ - (void)updateBlogWithRemoteBlog:(RemoteBlog *)remoteBlog account:(WPAccount *)a
- (void)updateBlog:(Blog *)blog withRemoteBlog:(RemoteBlog *)remoteBlog
{
- if (!blog.settings) {
- blog.settings = [self createSettingsWithBlog:blog];
- }
+ [blog addSettingsIfNecessary];
blog.url = remoteBlog.url;
blog.dotComID = remoteBlog.blogID;
+ blog.organizationID = remoteBlog.organizationID;
blog.isHostedAtWPcom = !remoteBlog.jetpack;
blog.icon = remoteBlog.icon;
blog.capabilities = remoteBlog.capabilities;
@@ -757,6 +525,7 @@ - (void)updateBlog:(Blog *)blog withRemoteBlog:(RemoteBlog *)remoteBlog
*/
- (Blog *)migrateRemoteJetpackBlog:(RemoteBlog *)remoteBlog
forAccount:(WPAccount *)account
+ inContext:(NSManagedObjectContext *)context
{
assert(remoteBlog.xmlrpc != nil);
NSURL *xmlrpcURL = [NSURL URLWithString:remoteBlog.xmlrpc];
@@ -767,7 +536,7 @@ - (Blog *)migrateRemoteJetpackBlog:(RemoteBlog *)remoteBlog
components.scheme = @"https";
}
NSURL *alternateXmlrpcURL = components.URL;
- NSArray *blogsWithNoAccount = [self blogsWithNoAccount];
+ NSArray *blogsWithNoAccount = [Blog selfHostedInContext:context];
Blog *jetpackBlog = [[blogsWithNoAccount wp_filter:^BOOL(Blog *blogToTest) {
return [blogToTest.xmlrpc caseInsensitiveCompare:xmlrpcURL.absoluteString] == NSOrderedSame
|| [blogToTest.xmlrpc caseInsensitiveCompare:alternateXmlrpcURL.absoluteString] == NSOrderedSame;
@@ -804,102 +573,13 @@ - (Blog *)migrateRemoteJetpackBlog:(RemoteBlog *)remoteBlog
return [[AccountServiceRemoteREST alloc] initWithWordPressComRestApi:account.wordPressComRestApi];
}
-- (Blog *)blogWithPredicate:(NSPredicate *)predicate
-{
- return [[self blogsWithPredicate:predicate] firstObject];
-}
-
-- (NSArray *)blogsWithPredicate:(NSPredicate *)predicate
-{
- NSFetchRequest *request = [self fetchRequestWithPredicate:predicate];
- NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"settings.name"
- ascending:YES];
- request.sortDescriptors = @[ sortDescriptor ];
-
- NSError *error;
- NSArray *results = [self.managedObjectContext executeFetchRequest:request
- error:&error];
- if (error) {
- DDLogError(@"Couldn't fetch blogs with predicate %@: %@", predicate, error);
- return nil;
- }
-
- return results;
-}
-
-- (NSInteger)blogCountWithPredicate:(NSPredicate *)predicate
-{
- NSFetchRequest *request = [self fetchRequestWithPredicate:predicate];
-
- NSError *err;
- NSUInteger count = [self.managedObjectContext countForFetchRequest:request
- error:&err];
- if (count == NSNotFound) {
- count = 0;
- }
- return count;
-}
-
-- (NSFetchRequest *)fetchRequestWithPredicate:(NSPredicate *)predicate
-{
- NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([Blog class])];
- request.includesSubentities = NO;
- request.predicate = predicate;
- return request;
-}
-
-- (NSPredicate *)predicateForVisibleBlogs
-{
- return [NSPredicate predicateWithFormat:@"visible = YES"];
-}
-
-
-- (NSPredicate *)predicateForVisibleBlogsWPComAccounts
-{
- NSArray *subpredicates = @[
- [self predicateForVisibleBlogs],
- [NSPredicate predicateWithFormat:@"account != NULL"],
- ];
- NSPredicate *predicate = [NSCompoundPredicate andPredicateWithSubpredicates:subpredicates];
-
- return predicate;
-}
-
-- (NSPredicate *)predicateForNoAccount
-{
- return [NSPredicate predicateWithFormat:@"account = NULL"];
-}
-
-- (NSUInteger)countForSyncedPostsWithEntityName:(NSString *)entityName
- forBlog:(Blog *)blog
-{
- __block NSUInteger count = 0;
- NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:entityName];
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"(remoteStatusNumber == %@) AND (postID != NULL) AND (original == NULL) AND (blog == %@)",
- [NSNumber numberWithInt:AbstractPostRemoteStatusSync],
- blog];
- [request setPredicate:predicate];
- NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"date_created_gmt"
- ascending:YES];
- [request setSortDescriptors:@[sortDescriptor]];
- request.includesSubentities = NO;
- request.resultType = NSCountResultType;
-
- [self.managedObjectContext performBlockAndWait:^{
- NSError *error = nil;
- count = [self.managedObjectContext countForFetchRequest:request
- error:&error];
- }];
- return count;
-}
-
#pragma mark - Completion handlers
- (void)updateMultiAuthor:(NSArray *)users forBlog:(NSManagedObjectID *)blogObjectID
{
- [self.managedObjectContext performBlock:^{
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
NSError *error;
- Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID error:&error];
+ Blog *blog = (Blog *)[context existingObjectWithID:blogObjectID error:&error];
if (error) {
DDLogError(@"%@", error);
}
@@ -907,7 +587,7 @@ - (void)updateMultiAuthor:(NSArray *)users forBlog:(NSManagedObjec
return;
}
- [self blogAuthorsFor:blog with:users];
+ [self updateBlogAuthorsForBlog:blog withRemoteUsers:users inContext:context];
blog.isMultiAuthor = users.count > 1;
/// Search for a matching user ID
@@ -933,7 +613,6 @@ - (void)updateMultiAuthor:(NSArray *)users forBlog:(NSManagedObjec
}
}
}
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
}];
}
@@ -941,19 +620,14 @@ - (BlogDetailsHandler)blogDetailsHandlerWithBlogObjectID:(NSManagedObjectID *)bl
completionHandler:(void (^)(void))completion
{
return ^void(RemoteBlog *remoteBlog) {
- [self.managedObjectContext performBlock:^{
- NSError *error = nil;
- Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID
- error:&error];
- if (blog) {
- [self updateBlog:blog withRemoteBlog:remoteBlog];
-
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
- }
-
- if (completion) {
- completion();
- }
+ [[[JetpackCapabilitiesService alloc] init] syncWithBlogs:@[remoteBlog] success:^(NSArray *blogs) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blog = (Blog *)[context existingObjectWithID:blogObjectID error:nil];
+ if (blog) {
+ [self updateBlog:blog withRemoteBlog:blogs.firstObject];
+ [self updatePromptSettingsFor:blogs.firstObject context:context];
+ }
+ } completion:completion onQueue:dispatch_get_main_queue()];
}];
};
}
@@ -962,35 +636,33 @@ - (OptionsHandler)optionsHandlerWithBlogObjectID:(NSManagedObjectID *)blogObject
completionHandler:(void (^)(void))completion
{
return ^void(NSDictionary *options) {
- [self.managedObjectContext performBlock:^{
- Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID
- error:nil];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blog = (Blog *)[context existingObjectWithID:blogObjectID error:nil];
if (!blog) {
- if (completion) {
- completion();
- }
return;
}
+
blog.options = [NSDictionary dictionaryWithDictionary:options];
RemoteBlogSettings *remoteSettings = [RemoteBlogOptionsHelper remoteBlogSettingsFromXMLRPCDictionaryOptions:options];
[self updateSettings:blog.settings withRemoteSettings:remoteSettings];
+
// NOTE: `[blog version]` can return nil. If this happens `version` will be `0`
CGFloat version = [[blog version] floatValue];
if (version > 0 && version < [WordPressMinimumVersion floatValue]) {
if (blog.lastUpdateWarning == nil
|| [blog.lastUpdateWarning floatValue] < [WordPressMinimumVersion floatValue])
{
- // TODO :: Remove UI call from service layer
- [WPError showAlertWithTitle:NSLocalizedString(@"WordPress version too old", @"")
- message:[NSString stringWithFormat:NSLocalizedString(@"The site at %@ uses WordPress %@. We recommend to update to the latest version, or at least %@", @""), [blog hostname], [blog version], WordPressMinimumVersion]];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ // TODO :: Remove UI call from service layer
+ [WPError showAlertWithTitle:NSLocalizedString(@"WordPress version too old", @"")
+ message:[NSString stringWithFormat:NSLocalizedString(@"The site at %@ uses WordPress %@. We recommend to update to the latest version, or at least %@", @""), [blog hostname], [blog version], WordPressMinimumVersion]];
+ });
blog.lastUpdateWarning = WordPressMinimumVersion;
}
}
-
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:completion];
- }];
+ } completion:completion onQueue:dispatch_get_main_queue()];
};
}
@@ -998,9 +670,8 @@ - (PostFormatsHandler)postFormatsHandlerWithBlogObjectID:(NSManagedObjectID *)bl
completionHandler:(void (^)(void))completion
{
return ^void(NSDictionary *postFormats) {
- [self.managedObjectContext performBlock:^{
- Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID
- error:nil];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blog = (Blog *)[context existingObjectWithID:blogObjectID error:nil];
if (blog) {
NSDictionary *formats = postFormats;
if (![formats objectForKey:PostFormatStandard]) {
@@ -1009,51 +680,18 @@ - (PostFormatsHandler)postFormatsHandlerWithBlogObjectID:(NSManagedObjectID *)bl
formats = [NSDictionary dictionaryWithDictionary:mutablePostFormats];
}
blog.postFormats = formats;
-
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
}
-
- if (completion) {
- completion();
- }
- }];
+ } completion:completion onQueue:dispatch_get_main_queue()];
};
}
-- (NSTimeZone *)timeZoneForBlog:(Blog *)blog
-{
- NSString *timeZoneName = [blog getOptionValue:@"timezone"];
- NSNumber *gmtOffSet = [blog getOptionValue:@"gmt_offset"];
- id optionValue = [blog getOptionValue:@"time_zone"];
-
- NSTimeZone *timeZone = nil;
- if (timeZoneName.length > 0) {
- timeZone = [NSTimeZone timeZoneWithName:timeZoneName];
- }
-
- if (!timeZone && gmtOffSet != nil) {
- timeZone = [NSTimeZone timeZoneForSecondsFromGMT:(gmtOffSet.floatValue * OneHourInSeconds)];
- }
-
- if (!timeZone && optionValue != nil) {
- NSInteger timeZoneOffsetSeconds = [optionValue floatValue] * OneHourInSeconds;
- timeZone = [NSTimeZone timeZoneForSecondsFromGMT:timeZoneOffsetSeconds];
- }
-
- if (!timeZone) {
- timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0];
- }
-
- return timeZone;
-}
-
- (void)updateSettings:(BlogSettings *)settings withRemoteSettings:(RemoteBlogSettings *)remoteSettings
{
NSParameterAssert(settings);
NSParameterAssert(remoteSettings);
// Transformables
- NSSet *separatedBlacklistKeys = [remoteSettings.commentsBlacklistKeys uniqueStringComponentsSeparatedByNewline];
+ NSSet *separatedBlocklistKeys = [remoteSettings.commentsBlocklistKeys uniqueStringComponentsSeparatedByNewline];
NSSet *separatedModerationKeys = [remoteSettings.commentsModerationKeys uniqueStringComponentsSeparatedByNewline];
// General
@@ -1075,10 +713,10 @@ - (void)updateSettings:(BlogSettings *)settings withRemoteSettings:(RemoteBlogSe
// Discussion
settings.commentsAllowed = [remoteSettings.commentsAllowed boolValue];
- settings.commentsBlacklistKeys = separatedBlacklistKeys;
+ settings.commentsBlocklistKeys = separatedBlocklistKeys;
settings.commentsCloseAutomatically = [remoteSettings.commentsCloseAutomatically boolValue];
settings.commentsCloseAutomaticallyAfterDays = remoteSettings.commentsCloseAutomaticallyAfterDays;
- settings.commentsFromKnownUsersWhitelisted = [remoteSettings.commentsFromKnownUsersWhitelisted boolValue];
+ settings.commentsFromKnownUsersAllowlisted = [remoteSettings.commentsFromKnownUsersAllowlisted boolValue];
settings.commentsMaximumLinks = remoteSettings.commentsMaximumLinks;
settings.commentsModerationKeys = separatedModerationKeys;
@@ -1123,7 +761,7 @@ - (RemoteBlogSettings *)remoteSettingFromSettings:(BlogSettings *)settings
RemoteBlogSettings *remoteSettings = [RemoteBlogSettings new];
// Transformables
- NSString *joinedBlacklistKeys = [[settings.commentsBlacklistKeys allObjects] componentsJoinedByString:@"\n"];
+ NSString *joinedBlocklistKeys = [[settings.commentsBlocklistKeys allObjects] componentsJoinedByString:@"\n"];
NSString *joinedModerationKeys = [[settings.commentsModerationKeys allObjects] componentsJoinedByString:@"\n"];
// General
@@ -1145,10 +783,10 @@ - (RemoteBlogSettings *)remoteSettingFromSettings:(BlogSettings *)settings
// Discussion
remoteSettings.commentsAllowed = @(settings.commentsAllowed);
- remoteSettings.commentsBlacklistKeys = joinedBlacklistKeys;
+ remoteSettings.commentsBlocklistKeys = joinedBlocklistKeys;
remoteSettings.commentsCloseAutomatically = @(settings.commentsCloseAutomatically);
remoteSettings.commentsCloseAutomaticallyAfterDays = settings.commentsCloseAutomaticallyAfterDays;
- remoteSettings.commentsFromKnownUsersWhitelisted = @(settings.commentsFromKnownUsersWhitelisted);
+ remoteSettings.commentsFromKnownUsersAllowlisted = @(settings.commentsFromKnownUsersAllowlisted);
remoteSettings.commentsMaximumLinks = settings.commentsMaximumLinks;
remoteSettings.commentsModerationKeys = joinedModerationKeys;
diff --git a/WordPress/Classes/Services/BlogSyncFacade.m b/WordPress/Classes/Services/BlogSyncFacade.m
index bd809dd35da2..5f1d443ea506 100644
--- a/WordPress/Classes/Services/BlogSyncFacade.m
+++ b/WordPress/Classes/Services/BlogSyncFacade.m
@@ -1,5 +1,5 @@
#import "BlogSyncFacade.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "BlogService.h"
#import "AccountService.h"
#import "Blog.h"
@@ -14,8 +14,7 @@ - (void)syncBlogsForAccount:(WPAccount *)account
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
{
- NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext];
- BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context];
+ BlogService *blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]];
[blogService syncBlogsForAccount:account success:^{
WP3DTouchShortcutCreator *shortcutCreator = [WP3DTouchShortcutCreator new];
[shortcutCreator createShortcutsIf3DTouchAvailable:YES];
@@ -32,16 +31,15 @@ - (void)syncBlogWithUsername:(NSString *)username
finishedSync:(void(^)(Blog *))finishedSync
{
NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext];
- BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context];
NSString *blogName = [options stringForKeyPath:@"blog_title.value"];
NSString *url = [options stringForKeyPath:@"home_url.value"];
if (!url) {
url = [options stringForKeyPath:@"blog_url.value"];
}
- Blog *blog = [blogService findBlogWithXmlrpc:xmlrpc andUsername:username];
+ Blog *blog = [Blog lookupWithUsername:username xmlrpc:xmlrpc inContext:context];
if (!blog) {
- blog = [blogService createBlogWithAccount:nil];
+ blog = [Blog createBlankBlogInContext:context];
if (url) {
blog.url = url;
}
@@ -70,8 +68,7 @@ - (void)syncBlogWithUsername:(NSString *)username
NSString *dotcomUsername = [blog getOptionValue:@"jetpack_user_login"];
if (dotcomUsername) {
// Search for a matching .com account
- AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context];
- WPAccount *account = [accountService findAccountWithUsername:dotcomUsername];
+ WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:context];
if (account) {
blog.account = account;
[WPAppAnalytics track:WPAnalyticsStatSignedInToJetpack withBlog:blog];
diff --git a/WordPress/Classes/Services/BloggingPromptsService.swift b/WordPress/Classes/Services/BloggingPromptsService.swift
new file mode 100644
index 000000000000..bd68c13a6df3
--- /dev/null
+++ b/WordPress/Classes/Services/BloggingPromptsService.swift
@@ -0,0 +1,332 @@
+import CoreData
+import WordPressKit
+
+class BloggingPromptsService {
+ private let contextManager: CoreDataStackSwift
+ private let siteID: NSNumber
+ private let remote: BloggingPromptsServiceRemote
+ private let calendar: Calendar = .autoupdatingCurrent
+ private let maxListPrompts = 11
+
+ /// A UTC date formatter that ignores time information.
+ private static var utcDateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = .init(identifier: "en_US_POSIX")
+ formatter.timeZone = .init(secondsFromGMT: 0)
+ formatter.dateFormat = "yyyy-MM-dd"
+
+ return formatter
+ }()
+
+ /// A date formatter using the local timezone that ignores time information.
+ private static var localDateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = .init(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd"
+
+ return formatter
+ }()
+
+ /// Convenience computed variable that returns today's prompt from local store.
+ ///
+ var localTodaysPrompt: BloggingPrompt? {
+ loadPrompts(from: Date(), number: 1).first
+ }
+
+ /// Convenience computed variable that returns prompt settings from the local store.
+ ///
+ var localSettings: BloggingPromptSettings? {
+ loadSettings(context: contextManager.mainContext)
+ }
+
+ /// Fetches a number of blogging prompts starting from the specified date.
+ /// When no parameters are specified, this method will attempt to return prompts from ten days ago and two weeks ahead.
+ ///
+ /// - Parameters:
+ /// - startDate: When specified, only prompts after the specified date will be returned. Defaults to 10 days ago.
+ /// - endDate: When specified, only prompts before the specified date will be returned.
+ /// - number: The amount of prompts to return. Defaults to 25 when unspecified (10 days back, today, 14 days ahead).
+ /// - success: Closure to be called when the fetch process succeeded.
+ /// - failure: Closure to be called when the fetch process failed.
+ func fetchPrompts(from startDate: Date? = nil,
+ to endDate: Date? = nil,
+ number: Int = 25,
+ success: (([BloggingPrompt]) -> Void)? = nil,
+ failure: ((Error?) -> Void)? = nil) {
+ let fromDate = startDate ?? defaultStartDate
+ remote.fetchPrompts(for: siteID, number: number, fromDate: fromDate) { result in
+ switch result {
+ case .success(let remotePrompts):
+ self.upsert(with: remotePrompts) { innerResult in
+ if case .failure(let error) = innerResult {
+ failure?(error)
+ return
+ }
+
+ success?(self.loadPrompts(from: fromDate, to: endDate, number: number))
+ }
+ case .failure(let error):
+ failure?(error)
+ }
+ }
+ }
+
+ /// Convenience method to fetch the blogging prompt for the current day.
+ ///
+ /// - Parameters:
+ /// - success: Closure to be called when the fetch process succeeded.
+ /// - failure: Closure to be called when the fetch process failed.
+ func fetchTodaysPrompt(success: ((BloggingPrompt?) -> Void)? = nil,
+ failure: ((Error?) -> Void)? = nil) {
+ fetchPrompts(from: Date(), number: 1, success: { (prompts) in
+ success?(prompts.first)
+ }, failure: failure)
+ }
+
+ /// Convenience method to obtain the blogging prompt for the current day,
+ /// either from local cache or remote.
+ ///
+ /// - Parameters:
+ /// - success: Closure to be called when the fetch process succeeded.
+ /// - failure: Closure to be called when the fetch process failed.
+ func todaysPrompt(success: @escaping (BloggingPrompt?) -> Void,
+ failure: @escaping (Error?) -> Void) {
+ guard localTodaysPrompt == nil else {
+ success(localTodaysPrompt)
+ return
+ }
+
+ fetchTodaysPrompt(success: success, failure: failure)
+ }
+
+ /// Convenience method to fetch the blogging prompts for the Prompts List.
+ /// Fetches 11 prompts - the current day and 10 previous.
+ ///
+ /// - Parameters:
+ /// - success: Closure to be called when the fetch process succeeded.
+ /// - failure: Closure to be called when the fetch process failed.
+ func fetchListPrompts(success: @escaping ([BloggingPrompt]) -> Void,
+ failure: @escaping (Error?) -> Void) {
+ fetchPrompts(from: listStartDate, to: Date(), number: maxListPrompts, success: success, failure: failure)
+ }
+
+ /// Loads a single prompt with the given `promptID`.
+ ///
+ /// - Parameters:
+ /// - promptID: The unique ID for the blogging prompt.
+ /// - blog: The blog associated with the prompt.
+ /// - Returns: The blogging prompt object if it exists, or nil otherwise.
+ func loadPrompt(with promptID: Int, in blog: Blog) -> BloggingPrompt? {
+ guard let siteID = blog.dotComID else {
+ return nil
+ }
+
+ let fetchRequest = BloggingPrompt.fetchRequest()
+ fetchRequest.predicate = NSPredicate(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.promptID)) = %@", siteID, NSNumber(value: promptID))
+ fetchRequest.fetchLimit = 1
+
+ return (try? self.contextManager.mainContext.fetch(fetchRequest))?.first
+ }
+
+ // MARK: - Settings
+
+ /// Fetches the blogging prompt settings for the configured `siteID`.
+ ///
+ /// - Parameters:
+ /// - success: Closure to be called on success with an optional `BloggingPromptSettings` object.
+ /// - failure: Closure to be called on failure with an optional `Error` object.
+ func fetchSettings(success: @escaping (BloggingPromptSettings?) -> Void,
+ failure: @escaping (Error?) -> Void) {
+ remote.fetchSettings(for: siteID) { result in
+ switch result {
+ case .success(let remoteSettings):
+ self.saveSettings(remoteSettings) {
+ let settings = self.loadSettings(context: self.contextManager.mainContext)
+ success(settings)
+ }
+ case .failure(let error):
+ failure(error)
+ }
+ }
+ }
+
+ /// Updates the blogging prompt settings for the configured `siteID`.
+ ///
+ /// - Parameters:
+ /// - settings: The new settings to update the remote with
+ /// - success: Closure to be called on success with an optional `BloggingPromptSettings` object. `nil` is passed
+ /// when the call is successful but there were no updated settings on the remote.
+ /// - failure: Closure to be called on failure with an optional `Error` object.
+ func updateSettings(settings: RemoteBloggingPromptsSettings,
+ success: @escaping (BloggingPromptSettings?) -> Void,
+ failure: @escaping (Error?) -> Void) {
+ remote.updateSettings(for: siteID, with: settings) { result in
+ switch result {
+ case .success(let remoteSettings):
+ guard let updatedSettings = remoteSettings else {
+ success(nil)
+ return
+ }
+ self.saveSettings(updatedSettings) {
+ let settings = self.loadSettings(context: self.contextManager.mainContext)
+ success(settings)
+ }
+ case .failure(let error):
+ failure(error)
+ }
+ }
+ }
+
+ // MARK: - Init
+
+ required init?(contextManager: CoreDataStackSwift = ContextManager.shared,
+ remote: BloggingPromptsServiceRemote? = nil,
+ blog: Blog? = nil) {
+ guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext),
+ let siteID = blog?.dotComID ?? account.primaryBlogID else {
+ return nil
+ }
+
+ self.contextManager = contextManager
+ self.siteID = siteID
+ self.remote = remote ?? .init(wordPressComRestApi: account.wordPressComRestV2Api)
+ }
+}
+
+// MARK: - Service Factory
+
+/// Convenience factory to generate `BloggingPromptsService` for different blogs.
+///
+class BloggingPromptsServiceFactory {
+ let contextManager: CoreDataStackSwift
+ let remote: BloggingPromptsServiceRemote?
+
+ init(contextManager: CoreDataStackSwift = ContextManager.shared, remote: BloggingPromptsServiceRemote? = nil) {
+ self.contextManager = contextManager
+ self.remote = remote
+ }
+
+ func makeService(for blog: Blog) -> BloggingPromptsService? {
+ return .init(contextManager: contextManager, remote: remote, blog: blog)
+ }
+}
+
+// MARK: - Private Helpers
+
+private extension BloggingPromptsService {
+
+ var defaultStartDate: Date {
+ calendar.date(byAdding: .day, value: -10, to: Date()) ?? Date()
+ }
+
+ var listStartDate: Date {
+ calendar.date(byAdding: .day, value: -(maxListPrompts - 1), to: Date()) ?? Date()
+ }
+
+ /// Converts the given date to UTC preserving the date and ignores the time information.
+ /// Examples:
+ /// Given `2022-05-01 23:00:00 UTC-4` (`2022-05-02 03:00:00 UTC`), this should return `2022-05-01 00:00:00 UTC`.
+ ///
+ /// Given `2022-05-02 05:00:00 UTC+9` (`2022-05-01 20:00:00 UTC`), this should return `2022-05-02 00:00:00 UTC`.
+ ///
+ /// - Parameter date: The date to convert.
+ /// - Returns: The UTC date without the time information.
+ func utcDateIgnoringTime(from date: Date?) -> Date? {
+ guard let date = date else {
+ return nil
+ }
+ let dateString = Self.localDateFormatter.string(from: date)
+ return Self.utcDateFormatter.date(from: dateString)
+ }
+
+ /// Loads local prompts based on the given parameters.
+ ///
+ /// - Parameters:
+ /// - startDate: Only prompts after the specified date will be returned.
+ /// - endDate: When specified, only prompts before the specified date will be returned.
+ /// - number: The amount of prompts to return.
+ /// - Returns: An array of `BloggingPrompt` objects sorted ascending by date.
+ func loadPrompts(from startDate: Date, to endDate: Date? = nil, number: Int) -> [BloggingPrompt] {
+ guard let utcStartDate = utcDateIgnoringTime(from: startDate) else {
+ DDLogError("Error converting date to UTC: \(startDate)")
+ return []
+ }
+
+ let fetchRequest = BloggingPrompt.fetchRequest()
+ if let utcEndDate = utcDateIgnoringTime(from: endDate) {
+ let format = "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.date)) >= %@ AND \(#keyPath(BloggingPrompt.date)) <= %@"
+ fetchRequest.predicate = NSPredicate(format: format, siteID, utcStartDate as NSDate, utcEndDate as NSDate)
+ } else {
+ let format = "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.date)) >= %@"
+ fetchRequest.predicate = NSPredicate(format: format, siteID, utcStartDate as NSDate)
+ }
+ fetchRequest.fetchLimit = number
+ fetchRequest.sortDescriptors = [.init(key: #keyPath(BloggingPrompt.date), ascending: true)]
+
+ return (try? self.contextManager.mainContext.fetch(fetchRequest)) ?? []
+ }
+
+ /// Find and update existing prompts, or insert new ones if they don't exist.
+ ///
+ /// - Parameters:
+ /// - remotePrompts: An array containing prompts obtained from remote.
+ /// - completion: Closure to be called after the process completes. Returns an array of prompts when successful.
+ func upsert(with remotePrompts: [RemoteBloggingPrompt], completion: @escaping (Result) -> Void) {
+ if remotePrompts.isEmpty {
+ completion(.success(()))
+ return
+ }
+
+ let remoteIDs = Set(remotePrompts.map { Int32($0.promptID) })
+ let remotePromptsDictionary = remotePrompts.reduce(into: [Int32: RemoteBloggingPrompt]()) { partialResult, remotePrompt in
+ partialResult[Int32(remotePrompt.promptID)] = remotePrompt
+ }
+
+ let predicate = NSPredicate(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.promptID)) IN %@", siteID, remoteIDs)
+ let fetchRequest = BloggingPrompt.fetchRequest()
+ fetchRequest.predicate = predicate
+
+ contextManager.performAndSave({ derivedContext in
+ var foundExistingIDs = [Int32]()
+ let results = try derivedContext.fetch(fetchRequest)
+ results.forEach { prompt in
+ guard let remotePrompt = remotePromptsDictionary[prompt.promptID] else {
+ return
+ }
+
+ foundExistingIDs.append(prompt.promptID)
+ prompt.configure(with: remotePrompt, for: self.siteID.int32Value)
+ }
+
+ // Insert new prompts
+ let newPromptIDs = remoteIDs.subtracting(foundExistingIDs)
+ newPromptIDs.forEach { newPromptID in
+ guard let remotePrompt = remotePromptsDictionary[newPromptID],
+ let newPrompt = BloggingPrompt.newObject(in: derivedContext) else {
+ return
+ }
+ newPrompt.configure(with: remotePrompt, for: self.siteID.int32Value)
+ }
+ }, completion: completion, on: .main)
+ }
+
+ /// Updates existing settings or creates new settings from the remote prompt settings.
+ ///
+ /// - Parameters:
+ /// - remoteSettings: The blogging prompt settings from the remote.
+ /// - completion: Closure to be called on completion.
+ func saveSettings(_ remoteSettings: RemoteBloggingPromptsSettings, completion: @escaping () -> Void) {
+ contextManager.performAndSave({ derivedContext in
+ let settings = self.loadSettings(context: derivedContext) ?? BloggingPromptSettings(context: derivedContext)
+ settings.configure(with: remoteSettings, siteID: self.siteID.int32Value, context: derivedContext)
+ }, completion: completion, on: .main)
+ }
+
+ func loadSettings(context: NSManagedObjectContext) -> BloggingPromptSettings? {
+ let fetchRequest = BloggingPromptSettings.fetchRequest()
+ fetchRequest.predicate = NSPredicate(format: "\(#keyPath(BloggingPromptSettings.siteID)) = %@", siteID)
+ fetchRequest.fetchLimit = 1
+ return (try? context.fetch(fetchRequest))?.first
+ }
+
+}
diff --git a/WordPress/Classes/Services/CommentService+Likes.swift b/WordPress/Classes/Services/CommentService+Likes.swift
new file mode 100644
index 000000000000..fa2d761440c2
--- /dev/null
+++ b/WordPress/Classes/Services/CommentService+Likes.swift
@@ -0,0 +1,124 @@
+extension CommentService {
+
+ /**
+ Fetches a list of users from remote that liked the comment with the given IDs.
+
+ @param commentID The ID of the comment to fetch likes for
+ @param siteID The ID of the site that contains the post
+ @param count Number of records to retrieve. Optional. Defaults to the endpoint max of 90.
+ @param before Filter results to likes before this date/time. Optional.
+ @param excludingIDs An array of user IDs to exclude from the returned results. Optional.
+ @param purgeExisting Indicates if existing Likes for the given post and site should be purged before
+ new ones are created. Defaults to true.
+ @param success A success block returning:
+ - Array of LikeUser
+ - Total number of likes for the given comment
+ - Number of likes per fetch
+ @param failure A failure block
+ */
+ func getLikesFor(commentID: NSNumber,
+ siteID: NSNumber,
+ count: Int = 90,
+ before: String? = nil,
+ excludingIDs: [NSNumber]? = nil,
+ purgeExisting: Bool = true,
+ success: @escaping (([LikeUser], Int, Int) -> Void),
+ failure: @escaping ((Error?) -> Void)) {
+
+ guard let remote = restRemote(forSite: siteID) else {
+ DDLogError("Unable to create a REST remote for comments.")
+ failure(nil)
+ return
+ }
+
+ remote.getLikesForCommentID(commentID,
+ count: NSNumber(value: count),
+ before: before,
+ excludeUserIDs: excludingIDs,
+ success: { remoteLikeUsers, totalLikes in
+ self.createNewUsers(from: remoteLikeUsers,
+ commentID: commentID,
+ siteID: siteID,
+ purgeExisting: purgeExisting) {
+ let users = self.likeUsersFor(commentID: commentID, siteID: siteID)
+ success(users, totalLikes.intValue, count)
+ LikeUserHelper.purgeStaleLikes()
+ }
+ }, failure: { error in
+ DDLogError(String(describing: error))
+ failure(error)
+ })
+ }
+
+ /**
+ Fetches a list of users from Core Data that liked the comment with the given IDs.
+
+ @param commentID The ID of the comment to fetch likes for.
+ @param siteID The ID of the site that contains the post.
+ @param after Filter results to likes after this Date. Optional.
+ */
+ func likeUsersFor(commentID: NSNumber, siteID: NSNumber, after: Date? = nil) -> [LikeUser] {
+ self.coreDataStack.performQuery { context in
+ let request = LikeUser.fetchRequest() as NSFetchRequest
+
+ request.predicate = {
+ if let after = after {
+ // The date comparison is 'less than' because Likes are in descending order.
+ return NSPredicate(format: "likedSiteID = %@ AND likedCommentID = %@ AND dateLiked < %@", siteID, commentID, after as CVarArg)
+ }
+
+ return NSPredicate(format: "likedSiteID = %@ AND likedCommentID = %@", siteID, commentID)
+ }()
+
+ request.sortDescriptors = [NSSortDescriptor(key: "dateLiked", ascending: false)]
+
+ if let users = try? context.fetch(request) {
+ return users
+ }
+
+ return [LikeUser]()
+ }
+ }
+
+}
+
+private extension CommentService {
+
+ func createNewUsers(from remoteLikeUsers: [RemoteLikeUser]?,
+ commentID: NSNumber,
+ siteID: NSNumber,
+ purgeExisting: Bool,
+ onComplete: @escaping (() -> Void)) {
+
+ guard let remoteLikeUsers = remoteLikeUsers,
+ !remoteLikeUsers.isEmpty else {
+ DispatchQueue.main.async {
+ onComplete()
+ }
+ return
+ }
+
+ coreDataStack.performAndSave({ derivedContext in
+ let likers = remoteLikeUsers.map { remoteUser in
+ LikeUserHelper.createOrUpdateFrom(remoteUser: remoteUser, context: derivedContext)
+ }
+
+ if purgeExisting {
+ self.deleteExistingUsersFor(commentID: commentID, siteID: siteID, from: derivedContext, likesToKeep: likers)
+ }
+ }, completion: onComplete, on: .main)
+ }
+
+ func deleteExistingUsersFor(commentID: NSNumber, siteID: NSNumber, from context: NSManagedObjectContext, likesToKeep: [LikeUser]) {
+ let request = LikeUser.fetchRequest() as NSFetchRequest
+ request.predicate = NSPredicate(format: "likedSiteID = %@ AND likedCommentID = %@ AND NOT (self IN %@)", siteID, commentID, likesToKeep)
+
+ do {
+ let users = try context.fetch(request)
+ users.forEach { context.delete($0) }
+ } catch {
+ DDLogError("Error fetching comment Like Users: \(error)")
+ }
+ }
+
+}
diff --git a/WordPress/Classes/Services/CommentService+Replies.swift b/WordPress/Classes/Services/CommentService+Replies.swift
new file mode 100644
index 000000000000..6bb4f98b6e71
--- /dev/null
+++ b/WordPress/Classes/Services/CommentService+Replies.swift
@@ -0,0 +1,89 @@
+/// Encapsulates actions related to fetching reply comments.
+///
+extension CommentService {
+ /// Fetches the current user's latest reply ID for the specified `parentID`.
+ /// In case if there are no replies found, the success block will still be called with value 0.
+ ///
+ /// - Parameters:
+ /// - parentID: The ID of the parent comment.
+ /// - siteID: The ID of the site containing the parent comment.
+ /// - accountService: Service dependency to fetch the current user's dotcom ID.
+ /// - success: Closure called when the fetch succeeds.
+ /// - failure: Closure called when the fetch fails.
+ func getLatestReplyID(for parentID: Int,
+ siteID: Int,
+ accountService: AccountService? = nil,
+ success: @escaping (Int) -> Void,
+ failure: @escaping (Error?) -> Void) {
+ guard let remote = restRemote(forSite: NSNumber(value: siteID)) else {
+ DDLogError("Unable to create a REST remote to fetch comment replies.")
+ failure(nil)
+ return
+ }
+
+ guard let userID = getCurrentUserID(accountService: accountService)?.intValue else {
+ DDLogError("Unable to find the current user's dotcom ID to fetch comment replies.")
+ failure(nil)
+ return
+ }
+
+ // If the current user does not have permission to the site, the `author` endpoint parameter is not permitted.
+ // Therefore, fetch all replies and filter for the current user here.
+ remote.getCommentsV2(for: siteID, parameters: [.parent: parentID]) { remoteComments in
+ // Filter for comments authored by the current user, and return the most recent commentID (if any).
+ success(remoteComments
+ .filter { $0.authorID == userID }
+ .sorted { $0.date > $1.date }.first?.commentID ?? 0)
+ } failure: { error in
+ failure(error)
+ }
+ }
+
+ /// Update the visibility of the comment's replies on the comment thread.
+ /// Note that this only applies to comments fetched from the Reader Comments section (i.e. has a reference to the `ReaderPost`).
+ ///
+ /// - Parameters:
+ /// - ancestorComment: The ancestor comment that will have its reply comments iterated.
+ /// - completion: The block executed after the replies are updated.
+ func updateRepliesVisibility(for ancestorComment: Comment, completion: (() -> Void)? = nil) {
+ guard let context = ancestorComment.managedObjectContext,
+ let post = ancestorComment.post as? ReaderPost,
+ let comments = post.comments as? Set else {
+ completion?()
+ return
+ }
+
+ let isVisible = (ancestorComment.status == CommentStatusType.approved.description)
+
+ // iterate over the ancestor comment's descendants and update their visibility for the comment thread.
+ //
+ // the hierarchy property stores ancestral info by storing a string version of its comment ID hierarchy,
+ // e.g.: "0000000012.0000000025.00000000035". The idea is to check if the ancestor comment's ID exists in the hierarchy.
+ // as an optimization, skip checking the hierarchy when the comment is the direct child of the ancestor comment.
+ context.perform {
+ comments.filter { comment in
+ comment.parentID == ancestorComment.commentID
+ || comment.hierarchy
+ .split(separator: ".")
+ .compactMap({ Int32($0) })
+ .contains(ancestorComment.commentID)
+ }.forEach { childComment in
+ childComment.visibleOnReader = isVisible
+ }
+
+ self.coreDataStack.save(context, completion: completion, on: .main)
+ }
+ }
+}
+
+private extension CommentService {
+ /// Returns the current user's dotcom ID.
+ ///
+ /// - Parameter accountService: The service used to fetch the default `WPAccount`.
+ /// - Returns: The current user's dotcom ID if exists, or nil otherwise.
+ func getCurrentUserID(accountService: AccountService? = nil) -> NSNumber? {
+ self.coreDataStack.performQuery { context in
+ (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.userID
+ }
+ }
+}
diff --git a/WordPress/Classes/Services/CommentService.h b/WordPress/Classes/Services/CommentService.h
index 692c19f2c62e..2798219e8789 100644
--- a/WordPress/Classes/Services/CommentService.h
+++ b/WordPress/Classes/Services/CommentService.h
@@ -1,5 +1,9 @@
#import
-#import "LocalCoreDataService.h"
+#import "CoreDataService.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@import WordPressKit;
extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage;
@@ -7,65 +11,101 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage;
@class Comment;
@class ReaderPost;
@class BasePost;
+@class RemoteUser;
+@class CommentServiceRemoteFactory;
-@interface CommentService : LocalCoreDataService
-
-+ (BOOL)isSyncingCommentsForBlog:(Blog *)blog;
+@interface CommentService : CoreDataService
-// Create comment
-- (Comment *)createCommentForBlog:(Blog *)blog;
+/// Initializes the instance with a custom service remote provider.
+///
+/// @param coreDataStack The `CoreDataStack` this instance will use for interacting with CoreData.
+/// @param commentServiceRemoteFactory The factory this instance will use to get service remote instances from.
+- (instancetype)initWithCoreDataStack:(id)coreDataStack
+ commentServiceRemoteFactory:(CommentServiceRemoteFactory *)remoteFactory NS_DESIGNATED_INITIALIZER;
// Create reply
-- (Comment *)createReplyForComment:(Comment *)comment;
-
-// Restore draft reply
-- (Comment *)restoreReplyForComment:(Comment *)comment;
-
-- (NSSet *)findCommentsWithPostID:(NSNumber *)postID inBlog:(Blog *)blog;
+- (void)createReplyForComment:(Comment *)comment content:(NSString *)content completion:(void (^)(Comment *reply))completion;
// Sync comments
- (void)syncCommentsForBlog:(Blog *)blog
- success:(void (^)(BOOL hasMore))success
- failure:(void (^)(NSError *error))failure;
+ withStatus:(CommentStatusFilter)status
+ success:(void (^ _Nullable)(BOOL hasMore))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
+
+- (void)syncCommentsForBlog:(Blog *)blog
+ withStatus:(CommentStatusFilter)status
+ filterUnreplied:(BOOL)filterUnreplied
+ success:(void (^ _Nullable)(BOOL hasMore))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Determine if a recent cache is available
+ (BOOL)shouldRefreshCacheFor:(Blog *)blog;
// Load extra comments
- (void)loadMoreCommentsForBlog:(Blog *)blog
- success:(void (^)(BOOL hasMore))success
- failure:(void (^)(NSError *))failure;
+ withStatus:(CommentStatusFilter)status
+ success:(void (^ _Nullable)(BOOL hasMore))success
+ failure:(void (^ _Nullable)(NSError * _Nullable))failure;
+
+// Load a single comment
+- (void)loadCommentWithID:(NSNumber *_Nonnull)commentID
+ forBlog:(Blog *_Nonnull)blog
+ success:(void (^_Nullable)(Comment *_Nullable))success
+ failure:(void (^_Nullable)(NSError *_Nullable))failure;
+
+- (void)loadCommentWithID:(NSNumber *_Nonnull)commentID
+ forPost:(ReaderPost *_Nonnull)post
+ success:(void (^_Nullable)(Comment *_Nullable))success
+ failure:(void (^_Nullable)(NSError *_Nullable))failure;
// Upload comment
- (void)uploadComment:(Comment *)comment
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Approve comment
- (void)approveComment:(Comment *)comment
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
-// Unapprove comment
+// Unapprove (Pending) comment
- (void)unapproveComment:(Comment *)comment
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Spam comment
- (void)spamComment:(Comment *)comment
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Trash comment
+- (void)trashComment:(Comment *)comment
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
+
+// Delete comment
- (void)deleteComment:(Comment *)comment
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
-// Sync a list of comments sorted by hierarchy
+// Sync a list of comments sorted by hierarchy, fetched by page number.
- (void)syncHierarchicalCommentsForPost:(ReaderPost *)post
page:(NSUInteger)page
- success:(void (^)(NSInteger count, BOOL hasMore))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(BOOL hasMore, NSNumber * _Nullable totalComments))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
+
+// Sync a list of comments sorted by hierarchy, restricted by the specified number of _top level_ comments.
+// This method is intended to get a small number of comments.
+// Therefore it is restricted to page 1 only.
+- (void)syncHierarchicalCommentsForPost:(ReaderPost *)post
+ topLevelComments:(NSUInteger)number
+ success:(void (^ _Nullable)(BOOL hasMore, NSNumber * _Nullable totalComments))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
+
+// Get the specified number of top level comments for the specified post.
+// This method is intended to get a small number of comments.
+// Therefore it is restricted to page 1 only.
+- (NSArray *)topLevelComments:(NSUInteger)number forPost:(ReaderPost *)post;
// Counts and returns the number of full pages of hierarchcial comments synced for a post.
// A partial set does not count toward the total number of pages.
@@ -84,62 +124,62 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage;
- (void)updateCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
content:(NSString *)content
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Replies
- (void)replyToPost:(ReaderPost *)post
content:(NSString *)content
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
- (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID
post:(ReaderPost *)post
content:(NSString *)content
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
- (void)replyToCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
content:(NSString *)content
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Like comment
- (void)likeCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Unlike comment
- (void)unlikeCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Approve comment
- (void)approveCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Unapprove comment
- (void)unapproveCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Spam comment
- (void)spamCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
// Delete comment
- (void)deleteCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
/**
This method will toggle the like status for a comment and optimistically save it. It will also
@@ -150,7 +190,18 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage;
*/
- (void)toggleLikeStatusForComment:(Comment *)comment
siteID:(NSNumber *)siteID
- success:(void (^)(void))success
- failure:(void (^)(NSError *error))failure;
+ success:(void (^ _Nullable)(void))success
+ failure:(void (^ _Nullable)(NSError * _Nullable error))failure;
+
+/**
+ Get a CommentServiceRemoteREST for the given site.
+ This is public so it can be accessed from Swift extensions.
+
+ @param siteID The ID of the site the remote will be used for.
+ */
+- (CommentServiceRemoteREST *_Nullable)restRemoteForSite:(NSNumber *_Nonnull)siteID;
+
@end
+
+NS_ASSUME_NONNULL_END
diff --git a/WordPress/Classes/Services/CommentService.m b/WordPress/Classes/Services/CommentService.m
index 318e509cc132..07055a1fe203 100644
--- a/WordPress/Classes/Services/CommentService.m
+++ b/WordPress/Classes/Services/CommentService.m
@@ -1,22 +1,41 @@
#import "CommentService.h"
#import "AccountService.h"
#import "Blog.h"
-#import "Comment.h"
-#import "ContextManager.h"
+#import "CoreDataStack.h"
#import "ReaderPost.h"
#import "WPAccount.h"
#import "PostService.h"
#import "AbstractPost.h"
#import "WordPress-Swift.h"
-@import WordPressKit;
NSUInteger const WPTopLevelHierarchicalCommentsPerPage = 20;
NSInteger const WPNumberOfCommentsToSync = 100;
static NSTimeInterval const CommentsRefreshTimeoutInSeconds = 60 * 5; // 5 minutes
+@interface CommentService ()
+
+@property (nonnull, strong, nonatomic) CommentServiceRemoteFactory *remoteFactory;
+
+@end
+
@implementation CommentService
+- (instancetype)initWithCoreDataStack:(id)coreDataStack
+{
+ return [self initWithCoreDataStack:coreDataStack commentServiceRemoteFactory:[CommentServiceRemoteFactory new]];
+}
+
+- (instancetype)initWithCoreDataStack:(id)coreDataStack
+ commentServiceRemoteFactory:(CommentServiceRemoteFactory *)remoteFactory
+{
+ self = [super initWithCoreDataStack:coreDataStack];
+ if (self) {
+ self.remoteFactory = remoteFactory;
+ }
+ return self;
+}
+
+ (NSMutableSet *)syncingCommentsLocks
{
static NSMutableSet *syncingCommentsLocks;
@@ -64,12 +83,6 @@ + (BOOL)shouldRefreshCacheFor:(Blog *)blog
return !isSyncing && (lastSynced == nil || ABS(lastSynced.timeIntervalSinceNow) > CommentsRefreshTimeoutInSeconds);
}
-- (NSSet *)findCommentsWithPostID:(NSNumber *)postID inBlog:(Blog *)blog
-{
- NSPredicate *predicate = [NSPredicate predicateWithFormat:@"postID = %@", postID];
- return [blog.comments filteredSetUsingPredicate:predicate];
-}
-
#pragma mark Public methods
#pragma mark Blog-centric methods
@@ -77,47 +90,49 @@ - (NSSet *)findCommentsWithPostID:(NSNumber *)postID inBlog:(Blog *)blog
// Create comment
- (Comment *)createCommentForBlog:(Blog *)blog
{
- Comment *comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) inManagedObjectContext:blog.managedObjectContext];
+ NSParameterAssert(blog.managedObjectContext != nil);
+
+ Comment *comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class])
+ inManagedObjectContext:blog.managedObjectContext];
+ comment.dateCreated = [NSDate new];
comment.blog = blog;
return comment;
}
// Create reply
-- (Comment *)createReplyForComment:(Comment *)comment
+- (void)createReplyForComment:(Comment *)comment content:(NSString *)content completion:(void (^)(Comment *reply))completion
{
- Comment *reply = [self createCommentForBlog:comment.blog];
- reply.postID = comment.postID;
- reply.post = comment.post;
- reply.parentID = comment.commentID;
- reply.status = CommentStatusApproved;
- return reply;
+ NSManagedObjectID *parentCommentID = comment.objectID;
+ NSManagedObjectID * __block replyID = nil;
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *comment = [context existingObjectWithID:parentCommentID error:nil];
+ Comment *reply = [self createCommentForBlog:comment.blog];
+ reply.postID = comment.postID;
+ reply.post = comment.post;
+ reply.parentID = comment.commentID;
+ reply.status = [Comment descriptionFor:CommentStatusTypeApproved];
+ reply.content = content;
+ [context obtainPermanentIDsForObjects:@[reply] error:nil];
+ replyID = reply.objectID;
+ } completion:^{
+ if (completion) {
+ completion([self.coreDataStack.mainContext existingObjectWithID:replyID error:nil]);
+ }
+ } onQueue:dispatch_get_main_queue()];
}
-// Restore draft reply
-- (Comment *)restoreReplyForComment:(Comment *)comment
+// Sync comments
+- (void)syncCommentsForBlog:(Blog *)blog
+ withStatus:(CommentStatusFilter)status
+ success:(void (^)(BOOL hasMore))success
+ failure:(void (^)(NSError *error))failure
{
- NSFetchRequest *existingReply = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([Comment class])];
- existingReply.predicate = [NSPredicate predicateWithFormat:@"status == %@ AND parentID == %@", CommentStatusDraft, comment.commentID];
- existingReply.fetchLimit = 1;
-
- NSError *error;
- NSArray *replies = [self.managedObjectContext executeFetchRequest:existingReply error:&error];
- if (error) {
- DDLogError(@"Failed to fetch reply: %@", error);
- }
-
- Comment *reply = [replies firstObject];
- if (!reply) {
- reply = [self createReplyForComment:comment];
- }
-
- reply.status = CommentStatusDraft;
-
- return reply;
+ [self syncCommentsForBlog:blog withStatus:status filterUnreplied:NO success:success failure:failure];
}
-// Sync comments
- (void)syncCommentsForBlog:(Blog *)blog
+ withStatus:(CommentStatusFilter)status
+ filterUnreplied:(BOOL)filterUnreplied
success:(void (^)(BOOL hasMore))success
failure:(void (^)(NSError *error))failure
{
@@ -129,54 +144,127 @@ - (void)syncCommentsForBlog:(Blog *)blog
}
return;
}
-
+
+ // If the comment status is not specified, default to all.
+ CommentStatusFilter commentStatus = status ?: CommentStatusFilterAll;
+ NSDictionary *options = @{ @"status": [NSNumber numberWithInt:commentStatus] };
+
id remote = [self remoteForBlog:blog];
- [remote getCommentsWithMaximumCount:WPNumberOfCommentsToSync success:^(NSArray *comments) {
- [self.managedObjectContext performBlock:^{
- Blog *blogInContext = (Blog *)[self.managedObjectContext existingObjectWithID:blogID error:nil];
- if (blogInContext) {
- [self mergeComments:comments
- forBlog:blog
- purgeExisting:YES
- completionHandler:^{
- [[self class] stopSyncingCommentsForBlog:blogID];
-
- [self.managedObjectContext performBlock:^{
- blogInContext.lastCommentsSync = [NSDate date];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{
- if (success) {
- // Note:
- // We'll assume that if the requested page size couldn't be filled, there are no
- // more comments left to retrieve.
- BOOL hasMore = comments.count >= WPNumberOfCommentsToSync;
- success(hasMore);
- }
- }];
- }];
- }];
- }
- }];
- } failure:^(NSError *error) {
- [[self class] stopSyncingCommentsForBlog:blogID];
- if (failure) {
- [self.managedObjectContext performBlock:^{
- failure(error);
- }];
- }
- }];
+
+ [remote getCommentsWithMaximumCount:WPNumberOfCommentsToSync
+ options:options
+ success:^(NSArray *comments) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blog = [context existingObjectWithID:blogID error:nil];
+ if (!blog) {
+ return;
+ }
+
+ NSArray *fetchedComments = comments;
+ if (filterUnreplied) {
+ NSString *author = @"";
+ if (blog.account) {
+ // See if there is a linked Jetpack user that we should use.
+ BlogAuthor *blogAuthor = [blog getAuthorWithLinkedID:blog.account.userID];
+ author = (blogAuthor) ? blogAuthor.email : blog.account.email;
+ } else {
+ BlogAuthor *blogAuthor = [blog getAuthorWithId:blog.userID];
+ author = (blogAuthor) ? blogAuthor.email : author;
+ }
+ fetchedComments = [self filterUnrepliedComments:comments forAuthor:author];
+ }
+ [self mergeComments:fetchedComments forBlog:blog purgeExisting:YES];
+ blog.lastCommentsSync = [NSDate date];
+ } completion:^{
+ [[self class] stopSyncingCommentsForBlog:blogID];
+
+ if (success) {
+ // Note:
+ // We'll assume that if the requested page size couldn't be filled, there are no
+ // more comments left to retrieve. However, for unreplied comments, we only fetch the first page (for now).
+ BOOL hasMore = comments.count >= WPNumberOfCommentsToSync && !filterUnreplied;
+ success(hasMore);
+ }
+ } onQueue:dispatch_get_main_queue()];
+ } failure:^(NSError *error) {
+ [[self class] stopSyncingCommentsForBlog:blogID];
+
+ if (failure) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ failure(error);
+ });
+ }
+ }];
+}
+
+- (NSArray *)filterUnrepliedComments:(NSArray *)comments forAuthor:(NSString *)author {
+ NSMutableArray *marr = [comments mutableCopy];
+
+ NSMutableArray *foundIDs = [NSMutableArray array];
+ NSMutableArray *discardables = [NSMutableArray array];
+
+ // get ids of comments that user has replied to.
+ for (RemoteComment *comment in marr) {
+ if (![comment.authorEmail isEqualToString:author] || !comment.parentID) {
+ continue;
+ }
+ [foundIDs addObject:comment.parentID];
+ [discardables addObject:comment];
+ }
+ // Discard the replies, they aren't needed.
+ [marr removeObjectsInArray:discardables];
+ [discardables removeAllObjects];
+
+ // Get the parents, grandparents etc. and discard those too.
+ while ([foundIDs count] > 0) {
+ NSArray *needles = [foundIDs copy];
+ [foundIDs removeAllObjects];
+ for (RemoteComment *comment in marr) {
+ if ([needles containsObject:comment.commentID]) {
+ if (comment.parentID) {
+ [foundIDs addObject:comment.parentID];
+ }
+ [discardables addObject:comment];
+ }
+ }
+ // Discard the matches, and keep looking if items were found.
+ [marr removeObjectsInArray:discardables];
+ [discardables removeAllObjects];
+ }
+
+ // remove any remaining child comments.
+ // remove any remaining root comments made by the user.
+ for (RemoteComment *comment in marr) {
+ if (comment.parentID.intValue != 0) {
+ [discardables addObject:comment];
+ } else if ([comment.authorEmail isEqualToString:author]) {
+ [discardables addObject:comment];
+ }
+ }
+ [marr removeObjectsInArray:discardables];
+
+ // these are the most recent unreplied comments from other users.
+ return [NSArray arrayWithArray:marr];
}
- (Comment *)oldestCommentForBlog:(Blog *)blog {
+ NSParameterAssert(blog.managedObjectContext != nil);
+
NSString *entityName = NSStringFromClass([Comment class]);
NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:entityName];
request.predicate = [NSPredicate predicateWithFormat:@"dateCreated != NULL && blog=%@", blog];
NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"dateCreated" ascending:YES];
request.sortDescriptors = @[sortDescriptor];
- Comment *oldestComment = [[self.managedObjectContext executeFetchRequest:request error:nil] firstObject];
+
+ Comment * __block oldestComment = nil;
+ [blog.managedObjectContext performBlockAndWait:^{
+ oldestComment = [[blog.managedObjectContext executeFetchRequest:request error:nil] firstObject];
+ }];
return oldestComment;
}
- (void)loadMoreCommentsForBlog:(Blog *)blog
+ withStatus:(CommentStatusFilter)status
success:(void (^)(BOOL hasMore))success
failure:(void (^)(NSError *))failure
{
@@ -188,8 +276,14 @@ - (void)loadMoreCommentsForBlog:(Blog *)blog
}
}
- id remote = [self remoteForBlog:blog];
NSMutableDictionary *options = [NSMutableDictionary dictionary];
+
+ // If the comment status is not specified, default to all.
+ CommentStatusFilter commentStatus = status ?: CommentStatusFilterAll;
+ options[@"status"] = [NSNumber numberWithInt:commentStatus];
+
+ id remote = [self remoteForBlog:blog];
+
if ([remote isKindOfClass:[CommentServiceRemoteREST class]]) {
Comment *oldestComment = [self oldestCommentForBlog:blog];
if (oldestComment.dateCreated) {
@@ -200,26 +294,111 @@ - (void)loadMoreCommentsForBlog:(Blog *)blog
NSUInteger commentCount = [blog.comments count];
options[@"offset"] = @(commentCount);
}
- [remote getCommentsWithMaximumCount:WPNumberOfCommentsToSync options:options success:^(NSArray *comments) {
- [self.managedObjectContext performBlock:^{
- Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogID error:nil];
+
+ [remote getCommentsWithMaximumCount:WPNumberOfCommentsToSync
+ options:options
+ success:^(NSArray *comments) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blog = [context existingObjectWithID:blogID error:nil];
if (!blog) {
return;
}
- [self mergeComments:comments forBlog:blog purgeExisting:NO completionHandler:^{
- [[self class] stopSyncingCommentsForBlog:blogID];
- if (success) {
- success(comments.count > 1);
- }
- }];
- }];
-
+ [self mergeComments:comments forBlog:blog purgeExisting:NO];
+ } completion:^{
+ [[self class] stopSyncingCommentsForBlog:blogID];
+ if (success) {
+ success(comments.count > 1);
+ }
+ } onQueue:dispatch_get_main_queue()];
} failure:^(NSError *error) {
[[self class] stopSyncingCommentsForBlog:blogID];
if (failure) {
- [self.managedObjectContext performBlock:^{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ failure(error);
+ });
+ }
+ }];
+}
+
+- (void)loadCommentWithID:(NSNumber *)commentID
+ forBlog:(Blog *)blog
+ success:(void (^)(Comment *comment))success
+ failure:(void (^)(NSError *))failure {
+
+ NSManagedObjectID *blogID = blog.objectID;
+ id remote = [self remoteForBlog:blog];
+
+ [remote getCommentWithID:commentID
+ success:^(RemoteComment *remoteComment) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blog = [context existingObjectWithID:blogID error:nil];
+ if (!blog) {
+ return;
+ }
+
+ Comment *comment = [blog commentWithID:remoteComment.commentID];
+ if (!comment) {
+ comment = [self createCommentForBlog:blog];
+ }
+
+ [self updateComment:comment withRemoteComment:remoteComment];
+ } completion:^{
+ if (success) {
+ [self.coreDataStack.mainContext performBlock:^{
+ Blog *blog = [self.coreDataStack.mainContext existingObjectWithID:blogID error:nil];
+ success([blog commentWithID:remoteComment.commentID]);
+ }];
+ }
+ } onQueue:dispatch_get_main_queue()];
+ } failure:^(NSError *error) {
+ DDLogError(@"Error loading comment for blog: %@", error);
+ if (failure) {
+ dispatch_async(dispatch_get_main_queue(), ^{
failure(error);
- }];
+ });
+ }
+ }];
+}
+
+- (void)loadCommentWithID:(NSNumber *)commentID
+ forPost:(ReaderPost *)post
+ success:(void (^)(Comment *comment))success
+ failure:(void (^)(NSError *))failure {
+
+ NSManagedObjectID *postID = post.objectID;
+ CommentServiceRemoteREST *service = [self restRemoteForSite:post.siteID];
+
+ [service getCommentWithID:commentID
+ success:^(RemoteComment *remoteComment) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ ReaderPost *post = [context existingObjectWithID:postID error:nil];
+ if (!post) {
+ return;
+ }
+
+ Comment *comment = [post commentWithID:remoteComment.commentID];
+
+ if (!comment) {
+ comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) inManagedObjectContext:context];
+ comment.dateCreated = [NSDate new];
+ }
+
+ comment.post = post;
+ [self updateComment:comment withRemoteComment:remoteComment];
+ } completion:^{
+ if (success) {
+ [self.coreDataStack.mainContext performBlock:^{
+ ReaderPost *post = [self.coreDataStack.mainContext existingObjectWithID:postID error:nil];
+ success([post commentWithID:remoteComment.commentID]);
+ }];
+ }
+ } onQueue:dispatch_get_main_queue()];
+ } failure:^(NSError *error) {
+ DDLogError(@"Error loading comment for post: %@", error);
+ if (failure) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ failure(error);
+ });
}
}];
}
@@ -229,24 +408,20 @@ - (void)uploadComment:(Comment *)comment
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
{
- id remote = [self remoteForBlog:comment.blog];
+ id remote = [self remoteForComment:comment];
RemoteComment *remoteComment = [self remoteCommentWithComment:comment];
NSManagedObjectID *commentObjectID = comment.objectID;
void (^successBlock)(RemoteComment *comment) = ^(RemoteComment *comment) {
- [self.managedObjectContext performBlock:^{
- Comment *commentInContext = (Comment *)[self.managedObjectContext existingObjectWithID:commentObjectID error:nil];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *commentInContext = [context existingObjectWithID:commentObjectID error:nil];
if (commentInContext) {
[self updateComment:commentInContext withRemoteComment:comment];
}
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
- if (success) {
- success();
- }
- }];
+ } completion:success onQueue:dispatch_get_main_queue()];
};
- if (comment.commentID) {
+ if (comment.commentID != 0) {
[remote updateComment:remoteComment
success:successBlock
failure:failure];
@@ -263,7 +438,7 @@ - (void)approveComment:(Comment *)comment
failure:(void (^)(NSError *error))failure
{
[self moderateComment:comment
- withStatus:CommentStatusApproved
+ withStatus:CommentStatusTypeApproved
success:success
failure:failure];
}
@@ -274,7 +449,7 @@ - (void)unapproveComment:(Comment *)comment
failure:(void (^)(NSError *error))failure
{
[self moderateComment:comment
- withStatus:CommentStatusPending
+ withStatus:CommentStatusTypePending
success:success
failure:failure];
}
@@ -284,17 +459,40 @@ - (void)spamComment:(Comment *)comment
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
{
+
+ // If the Comment is not permanently deleted, don't remove it from the local cache as it can still be displayed.
+ if (!comment.deleteWillBePermanent) {
+ [self moderateComment:comment
+ withStatus:CommentStatusTypeSpam
+ success:success
+ failure:failure];
+
+ return;
+ }
+
NSManagedObjectID *commentID = comment.objectID;
+
[self moderateComment:comment
- withStatus:CommentStatusSpam
+ withStatus:CommentStatusTypeSpam
success:^{
- Comment *commentInContext = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil];
- [self.managedObjectContext deleteObject:commentInContext];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
- if (success) {
- success();
- }
- } failure:failure];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *commentInContext = [context existingObjectWithID:commentID error:nil];
+ if (commentInContext != nil){
+ [context deleteObject:commentInContext];
+ }
+ } completion:success onQueue:dispatch_get_main_queue()];
+ } failure: failure];
+}
+
+// Trash comment
+- (void)trashComment:(Comment *)comment
+ success:(void (^)(void))success
+ failure:(void (^)(NSError *error))failure
+{
+ [self moderateComment:comment
+ withStatus:CommentStatusTypeUnapproved
+ success:success
+ failure:failure];
}
// Delete comment
@@ -302,83 +500,153 @@ - (void)deleteComment:(Comment *)comment
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
{
- NSNumber *commentID = comment.commentID;
- if (commentID) {
- RemoteComment *remoteComment = [self remoteCommentWithComment:comment];
- id remote = [self remoteForBlog:comment.blog];
+ // If this comment is local only, just delete. No need to query the endpoint or do any other work.
+ if (comment.commentID == 0) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *commentInContext = [context existingObjectWithID:comment.objectID error:nil];
+ if (commentInContext != nil) {
+ [context deleteObject:commentInContext];
+ }
+ } completion:success onQueue:dispatch_get_main_queue()];
+ return;
+ }
+
+ RemoteComment *remoteComment = [self remoteCommentWithComment:comment];
+ id remote = [self remoteForBlog:comment.blog];
+
+ // If the Comment is not permanently deleted, don't remove it from the local cache as it can still be displayed.
+ if (!comment.deleteWillBePermanent) {
[remote trashComment:remoteComment success:success failure:failure];
+ return;
}
- [self.managedObjectContext deleteObject:comment];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
-}
+ // For the best user experience we want to optimistically delete the comment.
+ // However, if there is an error we need to restore it.
+ NSManagedObjectID *blogObjID = comment.blog.objectID;
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *commentInContext = [context existingObjectWithID:comment.objectID error:nil];
+ if (commentInContext != nil) {
+ [context deleteObject:commentInContext];
+ }
+ } completion:^{
+ [remote trashComment:remoteComment success:success failure:^(NSError *error) {
+ // Failure. Restore the comment.
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Blog *blog = [context objectWithID:blogObjID];
+ if (!blog) {
+ return;
+ }
+
+ Comment *comment = [self createCommentForBlog:blog];
+ [self updateComment:comment withRemoteComment:remoteComment];
+ } completion:^{
+ if (failure) {
+ failure(error);
+ }
+ } onQueue:dispatch_get_main_queue()];
+ }];
+ } onQueue:dispatch_get_main_queue()];
+}
#pragma mark - Post-centric methods
- (void)syncHierarchicalCommentsForPost:(ReaderPost *)post
page:(NSUInteger)page
- success:(void (^)(NSInteger count, BOOL hasMore))success
+ success:(void (^)(BOOL hasMore, NSNumber *totalComments))success
+ failure:(void (^)(NSError *error))failure
+{
+ [self syncHierarchicalCommentsForPost:post
+ page:page
+ topLevelComments:WPTopLevelHierarchicalCommentsPerPage
+ success:success
+ failure:failure];
+}
+
+- (void)syncHierarchicalCommentsForPost:(ReaderPost *)post
+ topLevelComments:(NSUInteger)number
+ success:(void (^)(BOOL hasMore, NSNumber *totalComments))success
+ failure:(void (^)(NSError *error))failure
+{
+ [self syncHierarchicalCommentsForPost:post
+ page:1
+ topLevelComments:number
+ success:success
+ failure:failure];
+}
+
+- (void)syncHierarchicalCommentsForPost:(ReaderPost *)post
+ page:(NSUInteger)page
+ topLevelComments:(NSUInteger)number
+ success:(void (^)(BOOL hasMore, NSNumber *totalComments))success
failure:(void (^)(NSError *error))failure
{
NSManagedObjectID *postObjectID = post.objectID;
NSNumber *siteID = post.siteID;
NSNumber *postID = post.postID;
- [self.managedObjectContext performBlock:^{
- CommentServiceRemoteREST *service = [self restRemoteForSite:siteID];
- [service syncHierarchicalCommentsForPost:postID
- page:page
- number:WPTopLevelHierarchicalCommentsPerPage
- success:^(NSArray *comments) {
- [self.managedObjectContext performBlock:^{
+
+ NSUInteger commentsPerPage = number ?: WPTopLevelHierarchicalCommentsPerPage;
+ NSUInteger pageNumber = page ?: 1;
+
+ CommentServiceRemoteREST *service = [self restRemoteForSite:siteID];
+ [service syncHierarchicalCommentsForPost:postID
+ page:pageNumber
+ number:commentsPerPage
+ success:^(NSArray *comments, NSNumber *totalComments) {
+ BOOL __block includesNewComments = NO;
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ NSError *error;
+ ReaderPost *aPost = [context existingObjectWithID:postObjectID error:&error];
+ if (!aPost) {
+ if (failure) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ failure(error);
+ });
+ }
+ return;
+ }
+
+ includesNewComments = [self mergeHierarchicalComments:comments forPage:page forPost:aPost];
+ } completion:^{
+ if (!success) {
+ return;
+ }
+
+ [self.coreDataStack.mainContext performBlock:^{
NSError *error;
- ReaderPost *aPost = (ReaderPost *)[self.managedObjectContext existingObjectWithID:postObjectID error:&error];
+ ReaderPost *aPost = [self.coreDataStack.mainContext existingObjectWithID:postObjectID error:&error];
if (!aPost) {
if (failure) {
- dispatch_async(dispatch_get_main_queue(), ^{
- failure(error);
- });
+ failure(error);
}
return;
}
- [self mergeHierarchicalComments:comments forPage:page forPost:aPost onComplete:^(BOOL includesNewComments) {
- if (!success) {
- return;
- }
-
- [self.managedObjectContext performBlock:^{
- // There are no more comments when:
- // - There are fewer top level comments in the results than requested
- // - Page > 1, the number of top level comments matches those requested, but there are no new comments
- // We check this way because the API can return the last page of results instead
- // of returning zero results when the requested page is the last + 1.
- NSArray *parents = [self topLevelCommentsForPage:page forPost:aPost];
- BOOL hasMore = YES;
- if (([parents count] < WPTopLevelHierarchicalCommentsPerPage) || (page > 1 && !includesNewComments)) {
- hasMore = NO;
- }
-
- dispatch_async(dispatch_get_main_queue(), ^{
- success([comments count], hasMore);
- });
- }];
- }];
- }];
- } failure:^(NSError *error) {
- [self.managedObjectContext performBlock:^{
- if (failure) {
- dispatch_async(dispatch_get_main_queue(), ^{
- failure(error);
- });
+ // There are no more comments when:
+ // - There are fewer top level comments in the results than requested
+ // - Page > 1, the number of top level comments matches those requested, but there are no new comments
+ // We check this way because the API can return the last page of results instead
+ // of returning zero results when the requested page is the last + 1.
+ NSArray *parents = [self topLevelCommentsForPage:page forPost:aPost];
+ BOOL hasMore = YES;
+ if (([parents count] < WPTopLevelHierarchicalCommentsPerPage) || (page > 1 && !includesNewComments)) {
+ hasMore = NO;
}
+
+ success(hasMore, totalComments);
}];
- }];
- }];
+ } onQueue:dispatch_get_main_queue()];
+ } failure:^(NSError *error) {
+ if (failure) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ failure(error);
+ });
+ }
+ }];
}
- (NSInteger)numberOfHierarchicalPagesSyncedforPost:(ReaderPost *)post
{
- NSSet *topComments = [post.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"parentID = NULL"]];
+ NSSet *topComments = [post.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"parentID = 0"]];
CGFloat page = [topComments count] / WPTopLevelHierarchicalCommentsPerPage;
return (NSInteger)page;
}
@@ -415,50 +683,41 @@ - (void)replyToPost:(ReaderPost *)post
{
// Create and optimistically save a comment, based on the current wpcom acct
// post and content provided.
- Comment *comment = [self createHierarchicalCommentWithContent:content withParent:nil postID:post.postID siteID:post.siteID];
BOOL isPrivateSite = post.isPrivate;
-
- // This fixes an issue where the comment may not appear for some posts after a successful posting
- // More information: https://github.com/wordpress-mobile/WordPress-iOS/issues/13259
- comment.post = post;
+ [self createHierarchicalCommentWithContent:content withParent:nil postObjectID:post.objectID siteID:post.siteID completion:^(NSManagedObjectID *commentID) {
+ void (^successBlock)(RemoteComment *remoteComment) = ^void(RemoteComment *remoteComment) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *comment = [context existingObjectWithID:commentID error:nil];
+ if (!comment) {
+ return;
+ }
- NSManagedObjectID *commentID = comment.objectID;
- void (^successBlock)(RemoteComment *remoteComment) = ^void(RemoteComment *remoteComment) {
- [self.managedObjectContext performBlock:^{
- Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil];
- if (!comment) {
- return;
- }
+ remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite];
- remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite];
+ [self updateHierarchicalComment:comment withRemoteComment:remoteComment];
+ } completion:success onQueue:dispatch_get_main_queue()];
+ };
- // Update and save the comment
- [self updateCommentAndSave:comment withRemoteComment:remoteComment];
- if (success) {
- success();
- }
- }];
- };
-
- void (^failureBlock)(NSError *error) = ^void(NSError *error) {
- [self.managedObjectContext performBlock:^{
- Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil];
- if (!comment) {
- return;
- }
+ void (^failureBlock)(NSError *error) = ^void(NSError *error) {
// Remove the optimistically saved comment.
- [self deleteComment:comment success:nil failure:nil];
- if (failure) {
- failure(error);
- }
- }];
- };
-
- CommentServiceRemoteREST *remote = [self restRemoteForSite:post.siteID];
- [remote replyToPostWithID:post.postID
- content:content
- success:successBlock
- failure:failureBlock];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *commentInContext = [context existingObjectWithID:commentID error:nil];
+ if (commentInContext != nil) {
+ [context deleteObject:commentInContext];
+ }
+ } completion:^{
+ if (failure) {
+ failure(error);
+ }
+ } onQueue:dispatch_get_main_queue()];
+ };
+
+ CommentServiceRemoteREST *remote = [self restRemoteForSite:post.siteID];
+ [remote replyToPostWithID:post.postID
+ content:content
+ success:successBlock
+ failure:failureBlock];
+ }];
}
- (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID
@@ -469,52 +728,45 @@ - (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID
{
// Create and optimistically save a comment, based on the current wpcom acct
// post and content provided.
- Comment *comment = [self createHierarchicalCommentWithContent:content withParent:commentID postID:post.postID siteID:post.siteID];
BOOL isPrivateSite = post.isPrivate;
-
- // This fixes an issue where the comment may not appear for some posts after a successful posting
- // More information: https://github.com/wordpress-mobile/WordPress-iOS/issues/13259
- comment.post = post;
-
- NSManagedObjectID *commentObjectID = comment.objectID;
- void (^successBlock)(RemoteComment *remoteComment) = ^void(RemoteComment *remoteComment) {
- // Update and save the comment
- [self.managedObjectContext performBlock:^{
- Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentObjectID error:nil];
- if (!comment) {
- return;
- }
-
- remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite];
+ [self createHierarchicalCommentWithContent:content withParent:nil postObjectID:post.objectID siteID:post.siteID completion:^(NSManagedObjectID *commentObjectID) {
+ void (^successBlock)(RemoteComment *remoteComment) = ^void(RemoteComment *remoteComment) {
+ // Update and save the comment
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *comment = [context existingObjectWithID:commentObjectID error:nil];
+ if (!comment) {
+ return;
+ }
- [self updateCommentAndSave:comment withRemoteComment:remoteComment];
- if (success) {
- success();
- }
- }];
- };
+ remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite];
- void (^failureBlock)(NSError *error) = ^void(NSError *error) {
- [self.managedObjectContext performBlock:^{
- Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentObjectID error:nil];
- if (!comment) {
- return;
- }
- // Remove the optimistically saved comment.
- ReaderPost *post = (ReaderPost *)comment.post;
- post.commentCount = @([post.commentCount integerValue] - 1);
- [self deleteComment:comment success:nil failure:nil];
- if (failure) {
- failure(error);
- }
- }];
- };
+ [self updateHierarchicalComment:comment withRemoteComment:remoteComment];
+ } completion:success onQueue:dispatch_get_main_queue()];
+ };
- CommentServiceRemoteREST *remote = [self restRemoteForSite:post.siteID];
- [remote replyToCommentWithID:commentID
- content:content
- success:successBlock
- failure:failureBlock];
+ void (^failureBlock)(NSError *error) = ^void(NSError *error) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *commentInContext = [context existingObjectWithID:commentObjectID error:nil];
+ if (!commentInContext) {
+ return;
+ }
+ // Remove the optimistically saved comment.
+ [context deleteObject:commentInContext];
+ ReaderPost *post = (ReaderPost *)commentInContext.post;
+ post.commentCount = @([post.commentCount integerValue] - 1);
+ } completion:^{
+ if (failure) {
+ failure(error);
+ }
+ } onQueue:dispatch_get_main_queue()];
+ };
+
+ CommentServiceRemoteREST *remote = [self restRemoteForSite:post.siteID];
+ [remote replyToCommentWithID:commentID
+ content:content
+ success:successBlock
+ failure:failureBlock];
+ }];
}
- (void)replyToCommentWithID:(NSNumber *)commentID
@@ -526,7 +778,7 @@ - (void)replyToCommentWithID:(NSNumber *)commentID
CommentServiceRemoteREST *remote = [self restRemoteForSite:siteID];
[remote replyToCommentWithID:commentID
content:content
- success:^(RemoteComment *comment){
+ success:^(RemoteComment * __unused comment){
if (success){
success();
}
@@ -590,12 +842,11 @@ - (void)spamCommentWithID:(NSNumber *)commentID
{
CommentServiceRemoteREST *remote = [self restRemoteForSite:siteID];
[remote moderateCommentWithID:commentID
- status:CommentStatusSpam
+ status:[Comment descriptionFor:CommentStatusTypeSpam]
success:success
failure:failure];
}
-// Trash
- (void)deleteCommentWithID:(NSNumber *)commentID
siteID:(NSNumber *)siteID
success:(void (^)(void))success
@@ -612,112 +863,115 @@ - (void)toggleLikeStatusForComment:(Comment *)comment
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
{
- // toggle the like status and change the like count and save it
- comment.isLiked = !comment.isLiked;
- comment.likeCount = @([comment.likeCount intValue] + (comment.isLiked ? 1 : -1));
-
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
+ NSManagedObjectID *commentObjectID = comment.objectID;
+ BOOL isLikedOriginally = comment.isLiked;
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ // toggle the like status and change the like count and save it
+ Comment *comment = [context existingObjectWithID:commentObjectID error:nil];
+ comment.isLiked = !isLikedOriginally;
+ comment.likeCount = comment.likeCount + (comment.isLiked ? 1 : -1);
+ } completion:^{
+ // This block will reverse the like/unlike action
+ void (^failureBlock)(NSError *) = ^(NSError *error) {
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *comment = [context existingObjectWithID:commentObjectID error:nil];
+ DDLogError(@"Error while %@ comment: %@", comment.isLiked ? @"liking" : @"unliking", error);
+
+ comment.isLiked = isLikedOriginally;
+ comment.likeCount = comment.likeCount + (comment.isLiked ? 1 : -1);
+ } completion:^{
+ if (failure) {
+ failure(error);
+ }
+ } onQueue:dispatch_get_main_queue()];
+ };
- __weak __typeof(self) weakSelf = self;
- NSManagedObjectID *commentID = comment.objectID;
+ NSNumber *commentID = [NSNumber numberWithInt:comment.commentID];
- // This block will reverse the like/unlike action
- void (^failureBlock)(NSError *) = ^(NSError *error) {
- Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil];
- if (!comment) {
- return;
+ if (!isLikedOriginally) {
+ [self likeCommentWithID:commentID siteID:siteID success:success failure:failureBlock];
}
- DDLogError(@"Error while %@ comment: %@", comment.isLiked ? @"liking" : @"unliking", error);
-
- comment.isLiked = !comment.isLiked;
- comment.likeCount = @([comment.likeCount intValue] + (comment.isLiked ? 1 : -1));
-
- [[ContextManager sharedInstance] saveContext:weakSelf.managedObjectContext];
-
- if (failure) {
- failure(error);
+ else {
+ [self unlikeCommentWithID:commentID siteID:siteID success:success failure:failureBlock];
}
- };
-
- if (comment.isLiked) {
- [self likeCommentWithID:comment.commentID siteID:siteID success:success failure:failureBlock];
- }
- else {
- [self unlikeCommentWithID:comment.commentID siteID:siteID success:success failure:failureBlock];
- }
+ } onQueue:dispatch_get_main_queue()];
}
#pragma mark - Private methods
// Deletes orphaned comments. Does not save context.
-- (void)deleteUnownedComments
+- (void)deleteUnownedCommentsInContext:(NSManagedObjectContext *)context
{
NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:NSStringFromClass([Comment class])];
fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = NULL && blog = NULL"];
NSError *error;
- NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error];
+ NSArray *results = [context executeFetchRequest:fetchRequest error:&error];
if (error) {
DDLogError(@"Error fetching orphaned comments: %@", error);
}
for (Comment *comment in results) {
- [self.managedObjectContext deleteObject:comment];
+ [context deleteObject:comment];
}
}
+
+
#pragma mark - Blog centric methods
// Generic moderation
- (void)moderateComment:(Comment *)comment
- withStatus:(NSString *)status
+ withStatus:(CommentStatusType)status
success:(void (^)(void))success
failure:(void (^)(NSError *error))failure
{
+ NSString *currentStatus = [Comment descriptionFor:status];
NSString *prevStatus = comment.status;
- if ([prevStatus isEqualToString:status]) {
+
+ if ([prevStatus isEqualToString:currentStatus]) {
if (success) {
success();
}
return;
}
- comment.status = status;
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
- id remote = [self remoteForBlog:comment.blog];
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *commentInContext = [context existingObjectWithID:comment.objectID error:nil];
+ commentInContext.status = currentStatus;
+ }];
+
+ comment.status = currentStatus;
+
+ id remote = [self remoteForComment:comment];
RemoteComment *remoteComment = [self remoteCommentWithComment:comment];
- NSManagedObjectID *commentID = comment.objectID;
[remote moderateComment:remoteComment
- success:^(RemoteComment *comment) {
+ success:^(RemoteComment * __unused comment) {
if (success) {
success();
}
- } failure:^(NSError *error) {
- [self.managedObjectContext performBlock:^{
- // Note: The comment might have been deleted at this point
- Comment *commentInContext = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil];
- if (commentInContext) {
- commentInContext.status = prevStatus;
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
- }
-
+ } failure:^(NSError *error) {
+ DDLogError(@"Error moderating comment: %@", error);
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ Comment *commentInContext = [context existingObjectWithID:comment.objectID error:nil];
+ commentInContext.status = prevStatus;
+ } completion:^{
if (failure) {
- dispatch_async(dispatch_get_main_queue(), ^{
- failure(error);
- });
+ failure(error);
}
- }];
+ } onQueue:dispatch_get_main_queue()];
}];
}
- (void)mergeComments:(NSArray *)comments
forBlog:(Blog *)blog
purgeExisting:(BOOL)purgeExisting
- completionHandler:(void (^)(void))completion
{
+ NSParameterAssert(blog.managedObjectContext != nil);
+
NSMutableArray *commentsToKeep = [NSMutableArray array];
for (RemoteComment *remoteComment in comments) {
- Comment *comment = [self findCommentWithID:remoteComment.commentID inBlog:blog];
+ Comment *comment = [blog commentWithID:remoteComment.commentID];
if (!comment) {
comment = [self createCommentForBlog:blog];
}
@@ -730,26 +984,15 @@ - (void)mergeComments:(NSArray *)comments
if (existingComments.count > 0) {
for (Comment *comment in existingComments) {
// Don't delete unpublished comments
- if (![commentsToKeep containsObject:comment] && comment.commentID != nil) {
+ if (![commentsToKeep containsObject:comment] && comment.commentID != 0) {
DDLogInfo(@"Deleting Comment: %@", comment);
- [self.managedObjectContext deleteObject:comment];
+ [blog.managedObjectContext deleteObject:comment];
}
}
}
}
- [self deleteUnownedComments];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{
- if (completion) {
- dispatch_async(dispatch_get_main_queue(), completion);
- }
- }];
-}
-
-- (Comment *)findCommentWithID:(NSNumber *)commentID inBlog:(Blog *)blog
-{
- NSSet *comments = [blog.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"commentID = %@", commentID]];
- return [comments anyObject];
+ [self deleteUnownedCommentsInContext:blog.managedObjectContext];
}
#pragma mark - Post centric methods
@@ -759,7 +1002,7 @@ - (NSMutableArray *)ancestorsForCommentWithParentID:(NSNumber *)parentID andCurr
NSMutableArray *ancestors = [currentAncestors mutableCopy];
// Calculate hierarchy and depth.
- if (parentID) {
+ if (parentID.intValue != 0) {
if ([ancestors containsObject:parentID]) {
NSUInteger index = [ancestors indexOfObject:parentID] + 1;
NSArray *subarray = [ancestors subarrayWithRange:NSMakeRange(0, index)];
@@ -795,13 +1038,32 @@ - (NSString *)formattedHierarchyElement:(NSNumber *)commentID
return [NSString stringWithFormat:@"%010u", [commentID integerValue]];
}
-- (Comment *)createHierarchicalCommentWithContent:(NSString *)content withParent:(NSNumber *)parentID postID:(NSNumber *)postID siteID:(NSNumber *)siteID
+- (void)createHierarchicalCommentWithContent:(NSString *)content
+ withParent:(NSNumber *)parentID
+ postObjectID:(NSManagedObjectID *)postObjectID
+ siteID:(NSNumber *)siteID
+ completion:(void (^)(NSManagedObjectID *commentID))completion
+{
+ NSManagedObjectID * __block objectID = nil;
+ [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) {
+ ReaderPost *post = [context existingObjectWithID:postObjectID error:nil];
+ Comment *comment = [self createHierarchicalCommentWithContent:content withParent:parentID postID:post.postID siteID:siteID inContext:context];
+ objectID = comment.objectID;
+ // This fixes an issue where the comment may not appear for some posts after a successful posting
+ // More information: https://github.com/wordpress-mobile/WordPress-iOS/issues/13259
+ comment.post = post;
+ } completion:^{
+ completion(objectID);
+ } onQueue:dispatch_get_main_queue()];
+}
+
+- (Comment *)createHierarchicalCommentWithContent:(NSString *)content withParent:(NSNumber *)parentID postID:(NSNumber *)postID siteID:(NSNumber *)siteID inContext:(NSManagedObjectContext *)context
{
// Fetch the relevant ReaderPost
NSError *error;
NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:NSStringFromClass([ReaderPost class])];
fetchRequest.predicate = [NSPredicate predicateWithFormat:@"postID = %@ AND siteID = %@", postID, siteID];
- NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error];
+ NSArray *results = [context executeFetchRequest:fetchRequest error:&error];
if (error) {
DDLogError(@"Error fetching post with id %@ and site %@. %@", postID, siteID, error);
return nil;
@@ -814,45 +1076,48 @@ - (Comment *)createHierarchicalCommentWithContent:(NSString *)content withParent
// (Insert a new comment into core data. Check for its existance first for paranoia sake.
// In theory a sync could include a newly created comment before the request that created it returned.
- Comment *comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) inManagedObjectContext:self.managedObjectContext];
+ Comment *comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) inManagedObjectContext:context];
- AccountService *service = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext];
- comment.author = [[service defaultWordPressComAccount] username];
+ WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:context];
+ comment.author = [account username];
+ comment.authorID = [account.userID intValue];
comment.content = content;
comment.dateCreated = [NSDate date];
- comment.parentID = parentID;
- comment.postID = postID;
+ comment.parentID = [parentID intValue];
+ comment.postID = [postID intValue];
comment.postTitle = post.postTitle;
- comment.status = CommentStatusDraft;
+ comment.status = [Comment descriptionFor:CommentStatusTypeDraft];
comment.post = post;
- // Increment the post's comment count.
+ // Increment the post's comment count.
post.commentCount = @([post.commentCount integerValue] + 1);
// Find its parent comment (if it exists)
Comment *parentComment;
- if (parentID) {
- parentComment = [self findCommentWithID:parentID fromPost:post];
+ if (parentID.intValue != 0) {
+ parentComment = [post commentWithID:parentID];
}
// Update depth and hierarchy
- [self setHierarchAndDepthOnComment:comment withParentComment:parentComment];
+ [self setHierarchyAndDepthOnComment:comment withParentComment:parentComment];
- [self.managedObjectContext obtainPermanentIDsForObjects:@[comment] error:&error];
+ [context obtainPermanentIDsForObjects:@[comment] error:&error];
if (error) {
DDLogError(@"%@ error obtaining permanent ID for a hierarchical comment %@: %@", NSStringFromSelector(_cmd), comment, error);
}
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
return comment;
}
-- (void)setHierarchAndDepthOnComment:(Comment *)comment withParentComment:(Comment *)parentComment
+- (void)setHierarchyAndDepthOnComment:(Comment *)comment withParentComment:(Comment *)parentComment
{
+ NSParameterAssert(comment.managedObjectContext != nil);
+
// Update depth and hierarchy
- NSNumber *commentID = comment.commentID;
- if (!commentID) {
- // A new comment will have a nil commentID. If nil is used when formatting the hierarchy,
+ NSNumber *commentID = [NSNumber numberWithInt:comment.commentID];
+
+ if (commentID != 0) {
+ // A new comment will have a 0 commentID. If 0 is used when formatting the hierarchy,
// the comment will preceed any other comment in its level of the hierarchy.
// Instead we'll pass a number so large as to ensure the comment will appear last in a list.
commentID = @9999999;
@@ -860,56 +1125,67 @@ - (void)setHierarchAndDepthOnComment:(Comment *)comment withParentComment:(Comme
if (parentComment) {
comment.hierarchy = [NSString stringWithFormat:@"%@.%@", parentComment.hierarchy, [self formattedHierarchyElement:commentID]];
- comment.depth = @([parentComment.depth integerValue] + 1);
+ comment.depth = parentComment.depth + 1;
} else {
comment.hierarchy = [self formattedHierarchyElement:commentID];
- comment.depth = @(0);
+ comment.depth = 0;
}
-
- [self.managedObjectContext performBlock:^{
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
- }];
}
-- (void)updateCommentAndSave:(Comment *)comment withRemoteComment:(RemoteComment *)remoteComment
+- (void)updateHierarchicalComment:(Comment *)comment withRemoteComment:(RemoteComment *)remoteComment
{
+ NSParameterAssert(comment.managedObjectContext != nil);
+
[self updateComment:comment withRemoteComment:remoteComment];
// Find its parent comment (if it exists)
Comment *parentComment;
- if (comment.parentID) {
- parentComment = [self findCommentWithID:comment.parentID fromPost:(ReaderPost *)comment.post];
+ if (comment.parentID != 0) {
+ NSNumber *parentID = [NSNumber numberWithInt:comment.parentID];
+ parentComment = [(ReaderPost *)comment.post commentWithID:parentID];
}
// Update depth and hierarchy
- [self setHierarchAndDepthOnComment:comment withParentComment:parentComment];
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext];
+ [self setHierarchyAndDepthOnComment:comment withParentComment:parentComment];
}
-- (void)mergeHierarchicalComments:(NSArray *)comments forPage:(NSUInteger)page forPost:(ReaderPost *)post onComplete:(void (^)(BOOL includesNewComments))onComplete
+- (BOOL)mergeHierarchicalComments:(NSArray *)comments forPage:(NSUInteger)page forPost:(ReaderPost *)post
{
+ NSParameterAssert(post.managedObjectContext != nil);
+
if (![comments count]) {
- onComplete(NO);
- return;
+ return NO;
}
+ NSMutableSet *visibleCommentIds = [NSMutableSet new];
NSMutableArray *ancestors = [NSMutableArray array];
NSMutableArray *commentsToKeep = [NSMutableArray array];
NSString *entityName = NSStringFromClass([Comment class]);
NSUInteger newCommentCount = 0;
for (RemoteComment *remoteComment in comments) {
- Comment *comment = [self findCommentWithID:remoteComment.commentID fromPost:post];
+ Comment *comment = [post commentWithID:remoteComment.commentID];
if (!comment) {
newCommentCount++;
- comment = [NSEntityDescription insertNewObjectForEntityForName:entityName inManagedObjectContext:self.managedObjectContext];
+ comment = [NSEntityDescription insertNewObjectForEntityForName:entityName inManagedObjectContext:post.managedObjectContext];
}
[self updateComment:comment withRemoteComment:remoteComment];
// Calculate hierarchy and depth.
- ancestors = [self ancestorsForCommentWithParentID:comment.parentID andCurrentAncestors:ancestors];
- comment.hierarchy = [self hierarchyFromAncestors:ancestors andCommentID:comment.commentID];
- comment.depth = @([ancestors count]);
+ ancestors = [self ancestorsForCommentWithParentID:[NSNumber numberWithInt:comment.parentID] andCurrentAncestors:ancestors];
+ comment.hierarchy = [self hierarchyFromAncestors:ancestors andCommentID:[NSNumber numberWithInt:comment.commentID]];
+
+ // Comments are shown on the thread when (1) it is approved, and (2) its ancestors are approved.
+ // Having the comments sorted hierarchically ascending ensures that each comment's predecessors will be visited first.
+ // Therefore, we only need to check if the comment and its direct parent are approved.
+ // Ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/18081
+ BOOL hasValidParent = comment.parentID > 0 && [visibleCommentIds containsObject:@(comment.parentID)];
+ if ([comment isApproved] && ([comment isTopLevelComment] || hasValidParent)) {
+ [visibleCommentIds addObject:@(comment.commentID)];
+ }
+ comment.visibleOnReader = [visibleCommentIds containsObject:@(comment.commentID)];
+
+ comment.depth = ancestors.count;
comment.post = post;
comment.content = [self sanitizeCommentContent:comment.content isPrivateSite:post.isPrivate];
[commentsToKeep addObject:comment];
@@ -921,7 +1197,7 @@ - (void)mergeHierarchicalComments:(NSArray *)comments forPage:(NSUInteger)page f
// helps avoid certain cases where some pages might not be resynced, creating gaps in the content.
if (page == 1) {
[self deleteCommentsMissingFromHierarchicalComments:commentsToKeep forPost:post];
- [self deleteUnownedComments];
+ [self deleteUnownedCommentsInContext:post.managedObjectContext];
}
// Make sure the post's comment count is at least the number of comments merged.
@@ -929,9 +1205,7 @@ - (void)mergeHierarchicalComments:(NSArray *)comments forPage:(NSUInteger)page f
post.commentCount = @([commentsToKeep count]);
}
- [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{
- onComplete(newCommentCount > 0);
- }];
+ return newCommentCount > 0;
}
// Does not save context
@@ -939,7 +1213,7 @@ - (void)deleteCommentsMissingFromHierarchicalComments:(NSArray *)commentsToKeep
{
for (Comment *comment in post.comments) {
if (![commentsToKeep containsObject:comment]) {
- [self.managedObjectContext deleteObject:comment];
+ [post.managedObjectContext deleteObject:comment];
}
}
}
@@ -950,7 +1224,7 @@ - (NSArray *)topLevelCommentsForPage:(NSUInteger)page forPost:(ReaderPost *)post
// Retrieve the starting and ending comments for the specified page.
NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:entityName];
- fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = %@ AND parentID = NULL", post];
+ fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = %@ AND parentID = 0", post];
NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"hierarchy" ascending:YES];
fetchRequest.sortDescriptors = @[sortDescriptor];
[fetchRequest setFetchLimit:WPTopLevelHierarchicalCommentsPerPage];
@@ -958,114 +1232,106 @@ - (NSArray *)topLevelCommentsForPage:(NSUInteger)page forPost:(ReaderPost *)post
[fetchRequest setFetchOffset:offset];
NSError *error = nil;
- NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error];
+ NSArray *fetchedObjects = [post.managedObjectContext executeFetchRequest:fetchRequest error:&error];
if (error) {
DDLogError(@"Error fetching top level comments for page %i : %@", page, error);
}
return fetchedObjects;
}
-- (Comment *)firstCommentForPage:(NSUInteger)page forPost:(ReaderPost *)post
+- (NSArray *)topLevelComments:(NSUInteger)number forPost:(ReaderPost *)post
{
- NSArray *comments = [self topLevelCommentsForPage:page forPost:post];
- return [comments firstObject];
-}
-
-- (Comment *)lastCommentForPage:(NSUInteger)page forPost:(ReaderPost *)post
-{
- NSArray *comments = [self topLevelCommentsForPage:page forPost:post];
- Comment *lastParentComment = [comments lastObject];
-
- NSString *entityName = NSStringFromClass([Comment class]);
- NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:entityName];
- NSString *wildCard = [NSString stringWithFormat:@"%@*", lastParentComment.hierarchy];
- fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = %@ AND hierarchy LIKE %@", post, wildCard];
- NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"hierarchy" ascending:YES];
- fetchRequest.sortDescriptors = @[sortDescriptor];
-
- NSError *error = nil;
- NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error];
- if (error) {
- DDLogError(@"Error fetching last comment for page %i : %@", page, error);
- }
- return [fetchedObjects lastObject];
-}
-
-- (Comment *)findCommentWithID:(NSNumber *)commentID fromPost:(ReaderPost *)post
-{
- NSSet *comments = [post.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"commentID = %@", commentID]];
- return [comments anyObject];
+ NSParameterAssert(post.managedObjectContext != nil);
+ NSArray * __block commentsToReturn = nil;
+ [post.managedObjectContext performBlockAndWait:^{
+ NSArray *comments = [self topLevelCommentsForPage:1 forPost:post];
+ NSInteger count = MIN(comments.count, number);
+ commentsToReturn = [comments subarrayWithRange:NSMakeRange(0, count)];
+ }];
+ return commentsToReturn;
}
-
#pragma mark - Transformations
- (void)updateComment:(Comment *)comment withRemoteComment:(RemoteComment *)remoteComment
{
- comment.commentID = remoteComment.commentID;
+ NSParameterAssert(comment.managedObjectContext != nil);
+
+ comment.commentID = [remoteComment.commentID intValue];
+ comment.authorID = [remoteComment.authorID intValue];
comment.author = remoteComment.author;
comment.author_email = remoteComment.authorEmail;
comment.author_url = remoteComment.authorUrl;
comment.authorAvatarURL = remoteComment.authorAvatarURL;
+ comment.author_ip = remoteComment.authorIP;
comment.content = remoteComment.content;
+ comment.rawContent = remoteComment.rawContent;
comment.dateCreated = remoteComment.date;
comment.link = remoteComment.link;
- comment.parentID = remoteComment.parentID;
- comment.postID = remoteComment.postID;
+ comment.parentID = [remoteComment.parentID intValue];
+ comment.postID = [remoteComment.postID intValue];
comment.postTitle = remoteComment.postTitle;
comment.status = remoteComment.status;
comment.type = remoteComment.type;
comment.isLiked = remoteComment.isLiked;
- comment.likeCount = remoteComment.likeCount;
+ comment.likeCount = [remoteComment.likeCount intValue];
+ comment.canModerate = remoteComment.canModerate;
// if the post for the comment is not set, check if that post is already stored and associate them
if (!comment.post) {
- PostService *postService = [[PostService alloc] initWithManagedObjectContext:self.managedObjectContext];
- comment.post = [postService findPostWithID:comment.postID inBlog:comment.blog];
+ comment.post = [comment.blog lookupPostWithID:[NSNumber numberWithInt:comment.postID] inContext:comment.managedObjectContext];
}
}
- (RemoteComment *)remoteCommentWithComment:(Comment *)comment
{
RemoteComment *remoteComment = [RemoteComment new];
- remoteComment.commentID = comment.commentID;
+ remoteComment.commentID = [NSNumber numberWithInt:comment.commentID];
+ remoteComment.authorID = [NSNumber numberWithInt:comment.authorID];
remoteComment.author = comment.author;
remoteComment.authorEmail = comment.author_email;
remoteComment.authorUrl = comment.author_url;
remoteComment.authorAvatarURL = comment.authorAvatarURL;
+ remoteComment.authorIP = comment.author_ip;
remoteComment.content = comment.content;
remoteComment.date = comment.dateCreated;
remoteComment.link = comment.link;
- remoteComment.parentID = comment.parentID;
- remoteComment.postID = comment.postID;
+ remoteComment.parentID = [NSNumber numberWithInt:comment.parentID];
+ remoteComment.postID = [NSNumber numberWithInt:comment.postID];
remoteComment.postTitle = comment.postTitle;
remoteComment.status = comment.status;
remoteComment.type = comment.type;
remoteComment.isLiked = comment.isLiked;
- remoteComment.likeCount = comment.likeCount;
+ remoteComment.likeCount = [NSNumber numberWithInt:comment.likeCount];
+ remoteComment.canModerate = comment.canModerate;
return remoteComment;
}
#pragma mark - Remotes
-- (id)remoteForBlog:(Blog *)blog
+- (id)remoteForComment:(Comment *)comment
{
- idremote;
- // TODO: refactor API creation so it's not part of the model
- if ([blog supports:BlogFeatureWPComRESTAPI]) {
- if (blog.wordPressComRestApi) {
- remote = [[CommentServiceRemoteREST alloc] initWithWordPressComRestApi:blog.wordPressComRestApi siteID:blog.dotComID];
- }
- } else if (blog.xmlrpcApi) {
- remote = [[CommentServiceRemoteXMLRPC alloc] initWithApi:blog.xmlrpcApi username:blog.username password:blog.password];
+ NSParameterAssert(comment.managedObjectContext != nil);
+
+ // If the comment is fetched through the Reader API, the blog will always be nil.
+ // Try to find the Blog locally first, as it should exist if the user has a role on the site.
+ if (comment.post && [comment.post isKindOfClass:[ReaderPost class]]) {
+ ReaderPost *readerPost = (ReaderPost *)comment.post;
+ return [self remoteForBlog:[Blog lookupWithHostname:readerPost.blogURL inContext:comment.managedObjectContext]];
}
- return remote;
+
+ return [self remoteForBlog:comment.blog];
+}
+
+- (id)remoteForBlog:(Blog *)blog
+{
+ return [self.remoteFactory remoteWithBlog:blog];
}
- (CommentServiceRemoteREST *)restRemoteForSite:(NSNumber *)siteID
{
- return [[CommentServiceRemoteREST alloc] initWithWordPressComRestApi:[self apiForRESTRequest] siteID:siteID];
+ return [self.remoteFactory restRemoteWithSiteID:siteID api:[self apiForRESTRequest]];
}
/**
@@ -1073,8 +1339,11 @@ - (CommentServiceRemoteREST *)restRemoteForSite:(NSNumber *)siteID
*/
- (WordPressComRestApi *)apiForRESTRequest
{
- AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext];
- WPAccount *defaultAccount = [accountService defaultWordPressComAccount];
+ WPAccount * __block defaultAccount = nil;
+ [self.coreDataStack.mainContext performBlockAndWait:^{
+ defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext];
+ }];
+
WordPressComRestApi *api = [defaultAccount wordPressComRestApi];
//Sergio Estevao: Do we really want to do this? If the call going to be valid if no credential is available?
if (![api hasCredentials]) {
diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift
new file mode 100644
index 000000000000..5175d6d6a92b
--- /dev/null
+++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift
@@ -0,0 +1,38 @@
+import Foundation
+import WordPressKit
+
+
+/// Provides service remote instances for CommentService
+@objc class CommentServiceRemoteFactory: NSObject {
+
+ /// Returns a CommentServiceRemote for a given Blog object
+ ///
+ /// - Parameter blog: A valid Blog object
+ /// - Returns: A CommentServiceRemote instance
+ @objc func remote(blog: Blog) -> CommentServiceRemote? {
+ if blog.supports(.wpComRESTAPI),
+ let api = blog.wordPressComRestApi(),
+ let dotComID = blog.dotComID {
+ return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID)
+ }
+
+ if let api = blog.xmlrpcApi,
+ let username = blog.username,
+ let password = blog.password {
+ return CommentServiceRemoteXMLRPC(api: api, username: username, password: password)
+ }
+
+ return nil
+ }
+
+ /// Returns a REST remote for a given site ID.
+ ///
+ /// - Parameters:
+ /// - siteID: A valid siteID
+ /// - api: An instance of WordPressComRestAPI
+ /// - Returns: An instance of CommentServiceRemoteREST
+ @objc func restRemote(siteID: NSNumber, api: WordPressComRestApi) -> CommentServiceRemoteREST {
+ return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: siteID)
+ }
+
+}
diff --git a/WordPress/Classes/Services/CoreDataService.h b/WordPress/Classes/Services/CoreDataService.h
new file mode 100644
index 000000000000..fb4e9ae9aff8
--- /dev/null
+++ b/WordPress/Classes/Services/CoreDataService.h
@@ -0,0 +1,28 @@
+#import
+
+#import "CoreDataStack.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface CoreDataService : NSObject
+
+/**
+ * @brief This `CoreDataStack` object this instance will use for interacting with CoreData.
+ */
+@property (nonatomic, strong, readonly) id coreDataStack;
+
+/**
+ * @brief Initializes the instance.
+ *
+ * @param coreDataStack The `CoreDataStack` this instance will use for interacting with CoreData.
+ * Cannot be nil.
+ *
+ * @returns The initialized object.
+ */
+- (instancetype)initWithCoreDataStack:(id)coreDataStack NS_DESIGNATED_INITIALIZER;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/WordPress/Classes/Services/CoreDataService.m b/WordPress/Classes/Services/CoreDataService.m
new file mode 100644
index 000000000000..9bad4e03d531
--- /dev/null
+++ b/WordPress/Classes/Services/CoreDataService.m
@@ -0,0 +1,14 @@
+#import "CoreDataService.h"
+
+@implementation CoreDataService
+
+- (instancetype)initWithCoreDataStack:(id)coreDataStack
+{
+ self = [super init];
+ if (self) {
+ _coreDataStack = coreDataStack;
+ }
+ return self;
+}
+
+@end
diff --git a/WordPress/Classes/Services/CredentialsService.swift b/WordPress/Classes/Services/CredentialsService.swift
index 3f63b1e05d7c..3118b89ca3e9 100644
--- a/WordPress/Classes/Services/CredentialsService.swift
+++ b/WordPress/Classes/Services/CredentialsService.swift
@@ -10,13 +10,12 @@ struct KeychainCredentialsProvider: CredentialsProvider {
class CredentialsService {
private let provider: CredentialsProvider
- private let dotComOAuthKeychainService = "public-api.wordpress.com"
init(provider: CredentialsProvider = KeychainCredentialsProvider()) {
self.provider = provider
}
func getOAuthToken(site: JetpackSiteRef) -> String? {
- return provider.getPassword(username: site.username, service: dotComOAuthKeychainService)
+ return provider.getPassword(username: site.username, service: AppConstants.authKeychainServiceName)
}
}
diff --git a/WordPress/Classes/Services/DomainsService.swift b/WordPress/Classes/Services/DomainsService.swift
index b90d6dbbdbd1..96abfa1d4cf4 100644
--- a/WordPress/Classes/Services/DomainsService.swift
+++ b/WordPress/Classes/Services/DomainsService.swift
@@ -1,46 +1,114 @@
import Foundation
import CocoaLumberjack
import WordPressKit
+import CoreData
+
+struct FullyQuotedDomainSuggestion {
+ public let domainName: String
+ public let productID: Int?
+ public let supportsPrivacy: Bool?
+ public let costString: String
+ public let saleCostString: String?
+
+ /// Maps the suggestion to a DomainSuggestion we can use with out APIs.
+ func remoteSuggestion() -> DomainSuggestion {
+ DomainSuggestion(domainName: domainName,
+ productID: productID,
+ supportsPrivacy: supportsPrivacy,
+ costString: costString)
+ }
+}
struct DomainsService {
+ typealias RemoteDomainSuggestion = DomainSuggestion
+
let remote: DomainsServiceRemote
+ let productsRemote: ProductServiceRemote
- fileprivate let context: NSManagedObjectContext
+ private let coreDataStack: CoreDataStack
- init(managedObjectContext context: NSManagedObjectContext, remote: DomainsServiceRemote) {
- self.context = context
+ init(coreDataStack: CoreDataStack, remote: DomainsServiceRemote) {
+ self.coreDataStack = coreDataStack
self.remote = remote
+ self.productsRemote = ProductServiceRemote(restAPI: remote.wordPressComRestApi)
}
- func refreshDomainsForSite(_ siteID: Int, completion: @escaping (Bool) -> Void) {
+ /// Refreshes the domains for the specified site. Since this method takes care of merging the new data into our local
+ /// persistance layer making it useful to call even without knowing the result, the completion closure is optional.
+ ///
+ /// - Parameters:
+ /// - siteID: the ID of the site to refresh the domains for.
+ /// - completion: the result of the refresh request.
+ ///
+ func refreshDomains(siteID: Int, completion: ((Result) -> Void)? = nil) {
remote.getDomainsForSite(siteID, success: { domains in
- self.mergeDomains(domains, forSite: siteID)
- completion(true)
- }, failure: { error in
- completion(false)
+ self.coreDataStack.performAndSave({ context in
+ self.mergeDomains(domains, forSite: siteID, in: context)
+ }, completion: {
+ completion?(.success(()))
+ }, on: .main)
+ }, failure: { error in
+ completion?(.failure(error))
})
}
- func getDomainSuggestions(base: String,
- segmentID: Int64,
+ func getDomainSuggestions(query: String,
+ segmentID: Int64? = nil,
+ quantity: Int? = nil,
+ domainSuggestionType: DomainsServiceRemote.DomainSuggestionType? = nil,
success: @escaping ([DomainSuggestion]) -> Void,
failure: @escaping (Error) -> Void) {
- let request = DomainSuggestionRequest(query: base, segmentID: segmentID)
+ let request = DomainSuggestionRequest(query: query, segmentID: segmentID, quantity: quantity, suggestionType: domainSuggestionType)
remote.getDomainSuggestions(request: request,
success: { suggestions in
- let sorted = self.sortedSuggestions(suggestions, forBase: base)
- success(sorted)
+ let sorted = self.sortedSuggestions(suggestions, query: query)
+ success(sorted)
}) { error in
failure(error)
}
}
- func getDomainSuggestions(base: String,
- domainSuggestionType: DomainsServiceRemote.DomainSuggestionType = .onlyWordPressDotCom,
+ func getFullyQuotedDomainSuggestions(query: String,
+ segmentID: Int64? = nil,
+ quantity: Int? = nil,
+ domainSuggestionType: DomainsServiceRemote.DomainSuggestionType? = nil,
+ success: @escaping ([FullyQuotedDomainSuggestion]) -> Void,
+ failure: @escaping (Error) -> Void) {
+
+ productsRemote.getProducts { result in
+ switch result {
+ case .failure(let error):
+ failure(error)
+ case .success(let products):
+ getDomainSuggestions(query: query, segmentID: segmentID, quantity: quantity, domainSuggestionType: domainSuggestionType, success: { domainSuggestions in
+
+ success(domainSuggestions.map { remoteSuggestion in
+ let saleCostString = products.first() {
+ $0.id == remoteSuggestion.productID
+ }?.saleCostForDisplay()
+
+ return FullyQuotedDomainSuggestion(
+ domainName: remoteSuggestion.domainName,
+ productID: remoteSuggestion.productID,
+ supportsPrivacy: remoteSuggestion.supportsPrivacy,
+ costString: remoteSuggestion.costString,
+ saleCostString: saleCostString)
+ })
+ }, failure: failure)
+ }
+ }
+ }
+/*
+ func getDomainSuggestions(query: String,
+ quantity: Int? = nil,
+ domainSuggestionType: DomainSuggestionType = .onlyWordPressDotCom,
success: @escaping ([DomainSuggestion]) -> Void,
failure: @escaping (Error) -> Void) {
+ let request = DomainSuggestionRequest(query: query, quantity: quantity)
+
remote.getDomainSuggestions(base: base,
+ quantity: quantity,
domainSuggestionType: domainSuggestionType,
success: { suggestions in
let sorted = self.sortedSuggestions(suggestions, forBase: base)
@@ -48,15 +116,15 @@ struct DomainsService {
}) { error in
failure(error)
}
- }
+ }*/
// If any of the suggestions matches the base exactly,
// then sort that suggestion up to the top of the list.
- fileprivate func sortedSuggestions(_ suggestions: [DomainSuggestion], forBase base: String) -> [DomainSuggestion] {
- let normalizedBase = base.lowercased().replacingMatches(of: " ", with: "")
+ private func sortedSuggestions(_ suggestions: [RemoteDomainSuggestion], query: String) -> [RemoteDomainSuggestion] {
+ let normalizedQuery = query.lowercased().replacingMatches(of: " ", with: "")
var filteredSuggestions = suggestions
- if let matchedSuggestionIndex = suggestions.firstIndex(where: { $0.subdomain == base || $0.subdomain == normalizedBase }) {
+ if let matchedSuggestionIndex = suggestions.firstIndex(where: { $0.subdomain == query || $0.subdomain == normalizedQuery }) {
let matchedSuggestion = filteredSuggestions.remove(at: matchedSuggestionIndex)
filteredSuggestions.insert(matchedSuggestion, at: 0)
}
@@ -64,15 +132,15 @@ struct DomainsService {
return filteredSuggestions
}
- fileprivate func mergeDomains(_ domains: [Domain], forSite siteID: Int) {
+ private func mergeDomains(_ domains: [Domain], forSite siteID: Int, in context: NSManagedObjectContext) {
let remoteDomains = domains
- let localDomains = domainsForSite(siteID)
+ let localDomains = domainsForSite(siteID, in: context)
let remoteDomainNames = Set(remoteDomains.map({ $0.domainName }))
let localDomainNames = Set(localDomains.map({ $0.domainName }))
let removedDomainNames = localDomainNames.subtracting(remoteDomainNames)
- removeDomains(removedDomainNames, fromSite: siteID)
+ removeDomains(removedDomainNames, fromSite: siteID, in: context)
// Let's try to only update objects that have changed
let remoteChanges = remoteDomains.filter {
@@ -80,22 +148,18 @@ struct DomainsService {
}
for remoteDomain in remoteChanges {
- if let existingDomain = managedDomainWithName(remoteDomain.domainName, forSite: siteID),
- let blog = blogForSiteID(siteID) {
+ if let existingDomain = managedDomainWithName(remoteDomain.domainName, forSite: siteID, in: context),
+ let blog = blogForSiteID(siteID, in: context) {
existingDomain.updateWith(remoteDomain, blog: blog)
DDLogDebug("Updated domain \(existingDomain)")
} else {
- createManagedDomain(remoteDomain, forSite: siteID)
+ create(remoteDomain, forSite: siteID, in: context)
}
}
-
- ContextManager.sharedInstance().saveContextAndWait(context)
}
- fileprivate func blogForSiteID(_ siteID: Int) -> Blog? {
- let service = BlogService(managedObjectContext: context)
-
- guard let blog = service.blog(byBlogId: NSNumber(value: siteID)) else {
+ private func blogForSiteID(_ siteID: Int, in context: NSManagedObjectContext) -> Blog? {
+ guard let blog = try? Blog.lookup(withID: siteID, in: context) else {
let error = "Tried to obtain a Blog for a non-existing site (ID: \(siteID))"
assertionFailure(error)
DDLogError(error)
@@ -105,8 +169,8 @@ struct DomainsService {
return blog
}
- fileprivate func managedDomainWithName(_ domainName: String, forSite siteID: Int) -> ManagedDomain? {
- guard let blog = blogForSiteID(siteID) else { return nil }
+ private func managedDomainWithName(_ domainName: String, forSite siteID: Int, in context: NSManagedObjectContext) -> ManagedDomain? {
+ guard let blog = blogForSiteID(siteID, in: context) else { return nil }
let request = NSFetchRequest(entityName: ManagedDomain.entityName())
request.predicate = NSPredicate(format: "%K = %@ AND %K = %@", ManagedDomain.Relationships.blog, blog, ManagedDomain.Attributes.domainName, domainName)
@@ -115,16 +179,22 @@ struct DomainsService {
return results.first
}
- fileprivate func createManagedDomain(_ domain: Domain, forSite siteID: Int) {
- guard let blog = blogForSiteID(siteID) else { return }
+ func create(_ domain: Domain, forSite siteID: Int) {
+ coreDataStack.performAndSave { context in
+ self.create(domain, forSite: siteID, in: context)
+ }
+ }
+
+ private func create(_ domain: Domain, forSite siteID: Int, in context: NSManagedObjectContext) {
+ guard let blog = blogForSiteID(siteID, in: context) else { return }
let managedDomain = NSEntityDescription.insertNewObject(forEntityName: ManagedDomain.entityName(), into: context) as! ManagedDomain
managedDomain.updateWith(domain, blog: blog)
DDLogDebug("Created domain \(managedDomain)")
}
- fileprivate func domainsForSite(_ siteID: Int) -> [Domain] {
- guard let blog = blogForSiteID(siteID) else { return [] }
+ private func domainsForSite(_ siteID: Int, in context: NSManagedObjectContext) -> [Domain] {
+ guard let blog = blogForSiteID(siteID, in: context) else { return [] }
let request = NSFetchRequest(entityName: ManagedDomain.entityName())
request.predicate = NSPredicate(format: "%K == %@", ManagedDomain.Relationships.blog, blog)
@@ -140,8 +210,8 @@ struct DomainsService {
return domains.map { Domain(managedDomain: $0) }
}
- fileprivate func removeDomains(_ domainNames: Set, fromSite siteID: Int) {
- guard let blog = blogForSiteID(siteID) else { return }
+ private func removeDomains(_ domainNames: Set, fromSite siteID: Int, in context: NSManagedObjectContext) {
+ guard let blog = blogForSiteID(siteID, in: context) else { return }
let request = NSFetchRequest(entityName: ManagedDomain.entityName())
request.predicate = NSPredicate(format: "%K = %@ AND %K IN %@", ManagedDomain.Relationships.blog, blog, ManagedDomain.Attributes.domainName, domainNames)
@@ -154,7 +224,7 @@ struct DomainsService {
}
extension DomainsService {
- init(managedObjectContext context: NSManagedObjectContext, account: WPAccount) {
- self.init(managedObjectContext: context, remote: DomainsServiceRemote(wordPressComRestApi: account.wordPressComRestApi))
+ init(coreDataStack: CoreDataStack, account: WPAccount) {
+ self.init(coreDataStack: coreDataStack, remote: DomainsServiceRemote(wordPressComRestApi: account.wordPressComRestApi))
}
}
diff --git a/WordPress/Classes/Services/EditorSettingsService.swift b/WordPress/Classes/Services/EditorSettingsService.swift
index e707c074c896..61b8276f91e8 100644
--- a/WordPress/Classes/Services/EditorSettingsService.swift
+++ b/WordPress/Classes/Services/EditorSettingsService.swift
@@ -1,10 +1,17 @@
import Foundation
+import WordPressKit
@objc enum EditorSettingsServiceError: Int, Swift.Error {
case mobileEditorNotSet
}
-@objc class EditorSettingsService: LocalCoreDataService {
+@objc class EditorSettingsService: CoreDataService {
+
+ private lazy var coreDataStackSwift: CoreDataStackSwift = {
+ // The concrete type of coreDataStack is actually ContextManager, which also conforms to CoreDataStackSwift.
+ (coreDataStack as? CoreDataStackSwift) ?? ContextManager.shared
+ }()
+
@objc(syncEditorSettingsForBlog:success:failure:)
func syncEditorSettings(for blog: Blog, success: @escaping () -> Void, failure: @escaping (Swift.Error) -> Void) {
guard let api = api(for: blog) else {
@@ -19,15 +26,19 @@ import Foundation
let service = EditorServiceRemote(wordPressComRestApi: api)
service.getEditorSettings(siteID, success: { (settings) in
- do {
- try self.update(blog, remoteEditorSettings: settings)
- ContextManager.sharedInstance().save(self.managedObjectContext)
- success()
- } catch EditorSettingsServiceError.mobileEditorNotSet {
- self.migrateLocalSettingToRemote(for: blog, success: success, failure: failure)
- } catch {
- failure(error)
- }
+ self.coreDataStackSwift.performAndSave({ context in
+ let blogInContext = try context.existingObject(with: blog.objectID) as! Blog
+ try self.update(blogInContext, remoteEditorSettings: settings)
+ }, completion: { result in
+ switch result {
+ case .success:
+ success()
+ case .failure(EditorSettingsServiceError.mobileEditorNotSet):
+ self.migrateLocalSettingToRemote(for: blog, success: success, failure: failure)
+ case let .failure(error):
+ failure(error)
+ }
+ }, on: .main)
}, failure: failure)
}
@@ -78,7 +89,9 @@ import Foundation
private extension EditorSettingsService {
var defaultWPComAccount: WPAccount? {
- return AccountService(managedObjectContext: managedObjectContext).defaultWordPressComAccount()
+ coreDataStack.performQuery { context in
+ try? WPAccount.lookupDefaultWordPressComAccount(in: context)
+ }
}
func updateAllSites(with response: [Int: EditorSettings.Mobile]) {
diff --git a/WordPress/Classes/Services/FollowCommentsService.swift b/WordPress/Classes/Services/FollowCommentsService.swift
new file mode 100644
index 000000000000..114769ab502c
--- /dev/null
+++ b/WordPress/Classes/Services/FollowCommentsService.swift
@@ -0,0 +1,114 @@
+import Foundation
+import WordPressKit
+
+class FollowCommentsService: NSObject {
+
+ let post: ReaderPost
+ let remote: ReaderPostServiceRemote
+ private let coreDataStack: CoreDataStack
+
+ fileprivate let postID: Int
+ fileprivate let siteID: Int
+
+ required init?(
+ post: ReaderPost,
+ coreDataStack: CoreDataStack = ContextManager.shared,
+ remote: ReaderPostServiceRemote = ReaderPostServiceRemote.withDefaultApi()
+ ) {
+ guard let postID = post.postID as? Int,
+ let siteID = post.siteID as? Int
+ else {
+ return nil
+ }
+
+ self.post = post
+ self.coreDataStack = coreDataStack
+ self.postID = postID
+ self.siteID = siteID
+ self.remote = remote
+ }
+
+ @objc class func createService(with post: ReaderPost) -> FollowCommentsService? {
+ self.init(post: post)
+ }
+
+ /// Returns a Bool indicating whether or not the comments on the post can be followed.
+ ///
+ @objc var canFollowConversation: Bool {
+ return post.canSubscribeComments
+ }
+
+ /// Fetches the subscription status of the specified post for the current user.
+ ///
+ /// - Parameters:
+ /// - success: Success block called on a successful fetch.
+ /// - failure: Failure block called if there is any error.
+ @objc func fetchSubscriptionStatus(success: @escaping (Bool) -> Void,
+ failure: @escaping (Error?) -> Void) {
+ remote.fetchSubscriptionStatus(for: postID,
+ from: siteID,
+ success: success,
+ failure: failure)
+ }
+
+ /// Toggles the subscription status of the specified post.
+ ///
+ /// - Parameters:
+ /// - isSubscribed: The current subscription status for the reader post.
+ /// - success: Success block called on a successful fetch.
+ /// - failure: Failure block called if there is any error.
+ @objc func toggleSubscribed(_ isSubscribed: Bool,
+ success: @escaping (Bool) -> Void,
+ failure: @escaping (Error?) -> Void) {
+ let objID = post.objectID
+ let successBlock = { (taskSuccessful: Bool) -> Void in
+ self.coreDataStack.performAndSave({ context in
+ if let post = try? context.existingObject(with: objID) as? ReaderPost {
+ post.isSubscribedComments = !isSubscribed
+ }
+ }, completion: {
+ success(taskSuccessful)
+ }, on: .main)
+ }
+
+ if isSubscribed {
+ remote.unsubscribeFromPost(with: postID,
+ for: siteID,
+ success: successBlock,
+ failure: failure)
+ } else {
+ remote.subscribeToPost(with: postID,
+ for: siteID,
+ success: successBlock,
+ failure: failure)
+ }
+ }
+
+ /// Toggles the notification setting for a specified post.
+ ///
+ /// - Parameters:
+ /// - isNotificationsEnabled: Determines whether the user should receive notifications for new comments on the specified post.
+ /// - success: Block called after the operation completes successfully.
+ /// - failure: Block called when the operation fails.
+ @objc func toggleNotificationSettings(_ isNotificationsEnabled: Bool,
+ success: @escaping () -> Void,
+ failure: @escaping (Error?) -> Void) {
+
+ remote.updateNotificationSettingsForPost(with: postID, siteID: siteID, receiveNotifications: isNotificationsEnabled) { [weak self] in
+ guard let self = self else {
+ failure(nil)
+ return
+ }
+
+ self.coreDataStack.performAndSave({ context in
+ if let post = try? context.existingObject(with: self.post.objectID) as? ReaderPost {
+ post.receivesCommentNotifications = isNotificationsEnabled
+ }
+ }, completion: success, on: .main)
+ } failure: { error in
+ DDLogError("Error updating notification settings for followed conversation: \(String(describing: error))")
+ failure(error)
+ }
+ }
+
+}
diff --git a/WordPress/Classes/Services/HomepageSettingsService.swift b/WordPress/Classes/Services/HomepageSettingsService.swift
new file mode 100644
index 000000000000..67c7729ba6b2
--- /dev/null
+++ b/WordPress/Classes/Services/HomepageSettingsService.swift
@@ -0,0 +1,90 @@
+import Foundation
+import WordPressKit
+
+/// Service allowing updating of homepage settings
+///
+struct HomepageSettingsService {
+ public enum ResponseError: Error {
+ case decodingFailed
+ }
+
+ let blog: Blog
+
+ fileprivate let coreDataStack: CoreDataStack
+ fileprivate let remote: HomepageSettingsServiceRemote
+ fileprivate let siteID: Int
+
+ init?(blog: Blog, coreDataStack: CoreDataStack) {
+ guard let api = blog.wordPressComRestApi(), let dotComID = blog.dotComID as? Int else {
+ return nil
+ }
+
+ self.remote = HomepageSettingsServiceRemote(wordPressComRestApi: api)
+ self.siteID = dotComID
+ self.blog = blog
+ self.coreDataStack = coreDataStack
+ }
+
+ public func setHomepageType(_ type: HomepageType,
+ withPostsPageID postsPageID: Int? = nil,
+ homePageID: Int? = nil,
+ success: @escaping () -> Void,
+ failure: @escaping (Error) -> Void) {
+ var originalHomepageType: HomepageType?
+ var originalHomePageID: Int?
+ var originalPostsPageID: Int?
+ coreDataStack.performAndSave({ context in
+ guard let blog = Blog.lookup(withObjectID: self.blog.objectID, in: context) else {
+ return
+ }
+
+ // Keep track of the original settings in case we need to revert
+ originalHomepageType = blog.homepageType
+ originalHomePageID = blog.homepagePageID
+ originalPostsPageID = blog.homepagePostsPageID
+
+ switch type {
+ case .page:
+ blog.homepageType = .page
+ if let postsPageID = postsPageID {
+ blog.homepagePostsPageID = postsPageID
+ if postsPageID == originalHomePageID {
+ // Don't allow the same page to be set for both values
+ blog.homepagePageID = 0
+ }
+ }
+ if let homePageID = homePageID {
+ blog.homepagePageID = homePageID
+ if homePageID == originalPostsPageID {
+ // Don't allow the same page to be set for both values
+ blog.homepagePostsPageID = 0
+ }
+ }
+ case .posts:
+ blog.homepageType = .posts
+ }
+ }, completion: {
+ remote.setHomepageType(
+ type: type.remoteType,
+ for: siteID,
+ withPostsPageID: blog.homepagePostsPageID,
+ homePageID: blog.homepagePageID,
+ success: success,
+ failure: { error in
+ self.coreDataStack.performAndSave({ context in
+ guard let blog = Blog.lookup(withObjectID: self.blog.objectID, in: context) else {
+ return
+ }
+ blog.homepageType = originalHomepageType
+ blog.homepagePostsPageID = originalPostsPageID
+ blog.homepagePageID = originalHomePageID
+ }, completion: {
+ failure(error)
+ }, on: .main)
+ }
+ )
+ }, on: .main)
+
+
+ }
+}
diff --git a/WordPress/Classes/Services/JetpackBackupService.swift b/WordPress/Classes/Services/JetpackBackupService.swift
new file mode 100644
index 000000000000..06daa3cc5525
--- /dev/null
+++ b/WordPress/Classes/Services/JetpackBackupService.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+class JetpackBackupService {
+
+ private let coreDataStack: CoreDataStack
+
+ private lazy var service: JetpackBackupServiceRemote = {
+ var api: WordPressComRestApi!
+ coreDataStack.mainContext.performAndWait {
+ api = WordPressComRestApi.defaultApi(in: self.coreDataStack.mainContext, localeKey: WordPressComRestApi.LocaleKeyV2)
+ }
+
+ return JetpackBackupServiceRemote(wordPressComRestApi: api)
+ }()
+
+ init(coreDataStack: CoreDataStack) {
+ self.coreDataStack = coreDataStack
+ }
+
+ func prepareBackup(for site: JetpackSiteRef,
+ rewindID: String? = nil,
+ restoreTypes: JetpackRestoreTypes? = nil,
+ success: @escaping (JetpackBackup) -> Void,
+ failure: @escaping (Error) -> Void) {
+ service.prepareBackup(site.siteID, rewindID: rewindID, types: restoreTypes, success: success, failure: failure)
+ }
+
+ func getBackupStatus(for site: JetpackSiteRef, downloadID: Int, success: @escaping (JetpackBackup) -> Void, failure: @escaping (Error) -> Void) {
+ service.getBackupStatus(site.siteID, downloadID: downloadID, success: success, failure: failure)
+ }
+
+ func getAllBackupStatus(for site: JetpackSiteRef, success: @escaping ([JetpackBackup]) -> Void, failure: @escaping (Error) -> Void) {
+ service.getAllBackupStatus(site.siteID, success: success, failure: failure)
+ }
+
+ func dismissBackupNotice(site: JetpackSiteRef, downloadID: Int) {
+ service.markAsDismissed(site.siteID, downloadID: downloadID, success: {}, failure: { _ in })
+ }
+
+}
diff --git a/WordPress/Classes/Services/JetpackCapabilitiesService.swift b/WordPress/Classes/Services/JetpackCapabilitiesService.swift
new file mode 100644
index 000000000000..c6d8768e9f28
--- /dev/null
+++ b/WordPress/Classes/Services/JetpackCapabilitiesService.swift
@@ -0,0 +1,39 @@
+import WordPressKit
+
+@objc class JetpackCapabilitiesService: NSObject {
+
+ let capabilitiesServiceRemote: JetpackCapabilitiesServiceRemote
+
+ init(coreDataStack: CoreDataStack, capabilitiesServiceRemote: JetpackCapabilitiesServiceRemote?) {
+ if let capabilitiesServiceRemote {
+ self.capabilitiesServiceRemote = capabilitiesServiceRemote
+ } else {
+ let api = coreDataStack.performQuery {
+ WordPressComRestApi.defaultApi(in: $0, localeKey: WordPressComRestApi.LocaleKeyV2)
+ }
+
+ self.capabilitiesServiceRemote = JetpackCapabilitiesServiceRemote(wordPressComRestApi: api)
+ }
+ }
+
+ override convenience init() {
+ self.init(coreDataStack: ContextManager.shared, capabilitiesServiceRemote: nil)
+ }
+
+ /// Returns an array of [RemoteBlog] with the Jetpack capabilities added in `capabilities`
+ /// - Parameters:
+ /// - blogs: An array of RemoteBlog
+ /// - success: A block that accepts an array of RemoteBlog
+ @objc func sync(blogs: [RemoteBlog], success: @escaping ([RemoteBlog]) -> Void) {
+ capabilitiesServiceRemote.for(siteIds: blogs.compactMap { $0.blogID as? Int },
+ success: { capabilities in
+ blogs.forEach { blog in
+ if let cap = capabilities["\(blog.blogID)"] as? [String] {
+ cap.forEach { blog.capabilities[$0] = true }
+ }
+ }
+ success(blogs)
+ })
+ }
+
+}
diff --git a/WordPress/Classes/Services/JetpackNotificationMigrationService.swift b/WordPress/Classes/Services/JetpackNotificationMigrationService.swift
new file mode 100644
index 000000000000..bf50cfaf08b4
--- /dev/null
+++ b/WordPress/Classes/Services/JetpackNotificationMigrationService.swift
@@ -0,0 +1,206 @@
+import UIKit
+
+protocol JetpackNotificationMigrationServiceProtocol {
+ func shouldPresentNotifications() -> Bool
+}
+
+/// The service is created to support disabling WordPress notifications when Jetpack app enables notifications
+/// The service uses URLScheme to determine from Jetpack app if WordPress app is installed, open it, disable notifications and come back to Jetpack app
+/// This is a temporary solution to avoid duplicate notifications during the migration process from WordPress to Jetpack app
+/// This service and its usage can be deleted once the migration is done
+final class JetpackNotificationMigrationService: JetpackNotificationMigrationServiceProtocol {
+ private let remoteNotificationRegister: RemoteNotificationRegister
+ private let featureFlagStore: RemoteFeatureFlagStore
+ private let userDefaults: UserDefaults
+ private let isWordPress: Bool
+
+ static let shared = JetpackNotificationMigrationService()
+
+ static let wordPressScheme = "wordpressnotificationmigration"
+ static let jetpackScheme = "jetpacknotificationmigration"
+ private let wordPressNotificationsToggledDefaultsKey = "wordPressNotificationsToggledDefaultsKey"
+ private let jetpackNotificationMigrationDefaultsKey = "jetpackNotificationMigrationDefaultsKey"
+
+ private var jetpackMigrationPreventDuplicateNotifications: Bool {
+ return RemoteFeatureFlag.jetpackMigrationPreventDuplicateNotifications.enabled(using: featureFlagStore)
+ }
+
+ private lazy var notificationSettingsService: NotificationSettingsService? = {
+ NotificationSettingsService(coreDataStack: ContextManager.sharedInstance())
+ }()
+
+ private lazy var bloggingRemindersScheduler: BloggingRemindersScheduler? = {
+ try? BloggingRemindersScheduler(
+ notificationCenter: UNUserNotificationCenter.current(),
+ pushNotificationAuthorizer: InteractiveNotificationsManager.shared
+ )
+ }()
+
+ var wordPressNotificationsEnabled: Bool {
+ get {
+ /// UIApplication.shared.isRegisteredForRemoteNotifications should be always accessed from main thread
+ if Thread.isMainThread {
+ return remoteNotificationRegister.isRegisteredForRemoteNotifications
+ } else {
+ var isRegisteredForRemoteNotifications = false
+ DispatchQueue.main.sync {
+ isRegisteredForRemoteNotifications = remoteNotificationRegister.isRegisteredForRemoteNotifications
+ }
+ return isRegisteredForRemoteNotifications
+ }
+ }
+
+ set {
+ userDefaults.set(true, forKey: wordPressNotificationsToggledDefaultsKey)
+
+ if newValue, isWordPress {
+ remoteNotificationRegister.registerForRemoteNotifications()
+ rescheduleLocalNotifications()
+ } else if isWordPress {
+ remoteNotificationRegister.unregisterForRemoteNotifications()
+ }
+
+ if isWordPress && !newValue {
+ cancelAllPendingWordPressLocalNotifications()
+ }
+ }
+ }
+
+ /// Migration is supported if WordPress is compatible with the notification migration URLScheme
+ var isMigrationSupported: Bool {
+ guard let url = URL(string: "\(JetpackNotificationMigrationService.wordPressScheme)://") else {
+ return false
+ }
+
+ return UIApplication.shared.canOpenURL(url) && jetpackMigrationPreventDuplicateNotifications
+ }
+
+ /// disableWordPressNotificationsFromJetpack may get triggered multiple times from Jetpack app but it only needs to be executed the first time
+ private var isMigrationDone: Bool {
+ get {
+ return userDefaults.bool(forKey: jetpackNotificationMigrationDefaultsKey)
+ }
+ set {
+ userDefaults.setValue(newValue, forKey: jetpackNotificationMigrationDefaultsKey)
+ }
+ }
+
+ init(remoteNotificationRegister: RemoteNotificationRegister = UIApplication.shared,
+ featureFlagStore: RemoteFeatureFlagStore = RemoteFeatureFlagStore(),
+ userDefaults: UserDefaults = .standard,
+ isWordPress: Bool = AppConfiguration.isWordPress) {
+ self.remoteNotificationRegister = remoteNotificationRegister
+ self.featureFlagStore = featureFlagStore
+ self.userDefaults = userDefaults
+ self.isWordPress = isWordPress
+ }
+
+ func shouldShowNotificationControl() -> Bool {
+ return jetpackMigrationPreventDuplicateNotifications && isWordPress
+ }
+
+ func shouldPresentNotifications() -> Bool {
+ let notificationsDisabled = !JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled()
+ let appMigrated = jetpackMigrationPreventDuplicateNotifications
+ && isWordPress
+ && userDefaults.bool(forKey: wordPressNotificationsToggledDefaultsKey)
+ && !wordPressNotificationsEnabled
+ let disableNotifications = notificationsDisabled || appMigrated
+
+ if disableNotifications {
+ cancelAllPendingWordPressLocalNotifications()
+ }
+
+ return !disableNotifications
+ }
+
+ // MARK: - Only executed on Jetpack app
+
+ func disableWordPressNotificationsFromJetpack() {
+ guard !isMigrationDone, jetpackMigrationPreventDuplicateNotifications, !isWordPress else {
+ return
+ }
+
+ let wordPressUrl: URL? = {
+ var components = URLComponents()
+ components.scheme = JetpackNotificationMigrationService.wordPressScheme
+ return components.url
+ }()
+
+ /// Open WordPress app to disable notifications
+ if let url = wordPressUrl, UIApplication.shared.canOpenURL(url) {
+ isMigrationDone = true
+ UIApplication.shared.open(url)
+ }
+ }
+
+ // MARK: - Only executed on WordPress app
+
+ func handleNotificationMigrationOnWordPress() -> Bool {
+ guard isWordPress else {
+ return false
+ }
+
+ wordPressNotificationsEnabled = false
+
+ let jetpackUrl: URL? = {
+ var components = URLComponents()
+ components.scheme = JetpackNotificationMigrationService.jetpackScheme
+ return components.url
+ }()
+
+ /// Return to Jetpack app
+ if let url = jetpackUrl, UIApplication.shared.canOpenURL(url) {
+ UIApplication.shared.open(url)
+ }
+
+ return true
+ }
+
+ // MARK: - Local notifications
+
+ private func cancelAllPendingWordPressLocalNotifications(notificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current()) {
+ if isWordPress {
+ notificationCenter.removeAllPendingNotificationRequests()
+ }
+ }
+
+ func rescheduleLocalNotifications() {
+ DispatchQueue.main.async { [weak self] in
+ self?.rescheduleWeeklyRoundupNotifications()
+ self?.rescheduleBloggingReminderNotifications()
+ }
+ }
+
+ private func rescheduleWeeklyRoundupNotifications() {
+ WordPressAppDelegate.shared?.backgroundTasksCoordinator.scheduleTasks { _ in }
+ }
+
+ private func rescheduleBloggingReminderNotifications() {
+ notificationSettingsService?.getAllSettings { [weak self] settings in
+ for setting in settings {
+ if let blog = setting.blog,
+ let schedule = self?.bloggingRemindersScheduler?.schedule(for: blog),
+ let time = self?.bloggingRemindersScheduler?.scheduledTime(for: blog) {
+ if schedule != .none {
+ self?.bloggingRemindersScheduler?.schedule(schedule, for: blog, time: time) { result in
+ if case .success = result {
+ BloggingRemindersFlow.setHasShownWeeklyRemindersFlow(for: blog)
+ }
+ }
+ }
+ }
+ }
+ } failure: { _ in }
+ }
+}
+
+// MARK: - Helpers
+
+protocol RemoteNotificationRegister {
+ func registerForRemoteNotifications()
+ func unregisterForRemoteNotifications()
+ var isRegisteredForRemoteNotifications: Bool { get }
+}
+
+extension UIApplication: RemoteNotificationRegister {}
diff --git a/WordPress/Classes/Services/JetpackRestoreService.swift b/WordPress/Classes/Services/JetpackRestoreService.swift
new file mode 100644
index 000000000000..442e1cd0195c
--- /dev/null
+++ b/WordPress/Classes/Services/JetpackRestoreService.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+@objc class JetpackRestoreService: CoreDataService {
+
+ private lazy var serviceV1: ActivityServiceRemote_ApiVersion1_0 = {
+ let api = coreDataStack.performQuery {
+ WordPressComRestApi.defaultApi(in: $0)
+ }
+
+ return ActivityServiceRemote_ApiVersion1_0(wordPressComRestApi: api)
+ }()
+
+ private lazy var service: ActivityServiceRemote = {
+ let api = coreDataStack.performQuery {
+ WordPressComRestApi.defaultApi(in: $0, localeKey: WordPressComRestApi.LocaleKeyV2)
+ }
+
+ return ActivityServiceRemote(wordPressComRestApi: api)
+ }()
+
+ func restoreSite(_ site: JetpackSiteRef,
+ rewindID: String?,
+ restoreTypes: JetpackRestoreTypes? = nil,
+ success: @escaping (String, Int) -> Void,
+ failure: @escaping (Error) -> Void) {
+ guard let rewindID = rewindID else {
+ return
+ }
+ serviceV1.restoreSite(site.siteID, rewindID: rewindID, types: restoreTypes, success: success, failure: failure)
+ }
+
+ func getRewindStatus(for site: JetpackSiteRef, success: @escaping (RewindStatus) -> Void, failure: @escaping (Error) -> Void) {
+ service.getRewindStatus(site.siteID, success: success, failure: failure)
+ }
+
+}
diff --git a/WordPress/Classes/Services/JetpackScanService.swift b/WordPress/Classes/Services/JetpackScanService.swift
new file mode 100644
index 000000000000..2671f550da0b
--- /dev/null
+++ b/WordPress/Classes/Services/JetpackScanService.swift
@@ -0,0 +1,143 @@
+import Foundation
+
+@objc class JetpackScanService: CoreDataService {
+ private lazy var service: JetpackScanServiceRemote = {
+ let api = coreDataStack.performQuery {
+ WordPressComRestApi.defaultApi(in: $0, localeKey: WordPressComRestApi.LocaleKeyV2)
+ }
+
+ return JetpackScanServiceRemote(wordPressComRestApi: api)
+ }()
+
+ @objc func getScanAvailable(for blog: Blog, success: @escaping(Bool) -> Void, failure: @escaping(Error?) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.getScanAvailableForSite(siteID, success: success, failure: failure)
+ }
+
+ func getScan(for blog: Blog, success: @escaping(JetpackScan) -> Void, failure: @escaping(Error?) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.getScanForSite(siteID, success: success, failure: failure)
+ }
+
+ func getScanWithFixableThreatsStatus(for blog: Blog, success: @escaping(JetpackScan) -> Void, failure: @escaping(Error?) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.getScanForSite(siteID, success: { [weak self] scanObj in
+ // Only check if we're in the idle state, ie: not scanning or preparing to scan
+ // The result does not have any fixable threats, we don't need to get the statuses for them
+ guard scanObj.state == .idle, scanObj.hasFixableThreats, let fixableThreats = scanObj.fixableThreats else {
+ success(scanObj)
+ return
+ }
+
+ self?.getFixStatusForThreats(fixableThreats, blog: blog, success: { fixResponse in
+ // We're not fixing any threats, just return the original scan object
+ guard fixResponse.isFixingThreats else {
+ success(scanObj)
+ return
+ }
+
+ // Make a copy of the object so we can modify the state / fixing status
+ var returnObj = scanObj
+ returnObj.state = .fixingThreats
+
+ // Map the threat Ids to Threats
+ let threats = returnObj.fixableThreats ?? []
+ var inProgressThreats: [JetpackThreatFixStatus] = []
+
+ for item in fixResponse.threats {
+ // Filter any fixable threats that may not be actively being fixed
+ if item.status == .notStarted {
+ continue
+ }
+
+ var threat = threats.filter({ $0.id == item.threatId }).first
+ if item.status == .inProgress {
+ threat?.status = .fixing
+ }
+
+ var fixStatus = item
+ fixStatus.threat = threat
+ inProgressThreats.append(fixStatus)
+ }
+
+ returnObj.threatFixStatus = inProgressThreats
+
+ //
+ success(returnObj)
+ }, failure: failure)
+ }, failure: failure)
+ }
+
+ func startScan(for blog: Blog, success: @escaping(Bool) -> Void, failure: @escaping(Error?) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.startScanForSite(siteID, success: success, failure: failure)
+ }
+
+ // MARK: - Threats
+ func fixThreats(_ threats: [JetpackScanThreat], blog: Blog, success: @escaping(JetpackThreatFixResponse) -> Void, failure: @escaping(Error) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.fixThreats(threats, siteID: siteID, success: success, failure: failure)
+ }
+
+ func fixThreat(_ threat: JetpackScanThreat, blog: Blog, success: @escaping(JetpackThreatFixStatus) -> Void, failure: @escaping(Error) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.fixThreat(threat, siteID: siteID, success: success, failure: failure)
+ }
+
+ public func getFixStatusForThreats(_ threats: [JetpackScanThreat], blog: Blog, success: @escaping(JetpackThreatFixResponse) -> Void, failure: @escaping(Error) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.getFixStatusForThreats(threats, siteID: siteID, success: success, failure: failure)
+ }
+
+ func ignoreThreat(_ threat: JetpackScanThreat, blog: Blog, success: @escaping() -> Void, failure: @escaping(Error) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.ignoreThreat(threat, siteID: siteID, success: success, failure: failure)
+ }
+
+ // MARK: - History
+ func getHistory(for blog: Blog, success: @escaping(JetpackScanHistory) -> Void, failure: @escaping(Error) -> Void) {
+ guard let siteID = blog.dotComID?.intValue else {
+ failure(JetpackScanServiceError.invalidSiteID)
+ return
+ }
+
+ service.getHistoryForSite(siteID, success: success, failure: failure)
+ }
+
+ // MARK: - Helpers
+ enum JetpackScanServiceError: Error {
+ case invalidSiteID
+ }
+}
diff --git a/WordPress/Classes/Services/JetpackWebViewControllerFactory.swift b/WordPress/Classes/Services/JetpackWebViewControllerFactory.swift
new file mode 100644
index 000000000000..e3d53f3d23b8
--- /dev/null
+++ b/WordPress/Classes/Services/JetpackWebViewControllerFactory.swift
@@ -0,0 +1,12 @@
+import UIKit
+
+class JetpackWebViewControllerFactory {
+
+ static func settingsController(siteID: Int) -> UIViewController? {
+ guard let url = URL(string: "https://wordpress.com/settings/jetpack/\(siteID)") else {
+ return nil
+ }
+ return WebViewControllerFactory.controller(url: url, source: "jetpack_web_settings")
+ }
+
+}
diff --git a/WordPress/Classes/Services/LikeUserHelpers.swift b/WordPress/Classes/Services/LikeUserHelpers.swift
new file mode 100644
index 000000000000..b96f0df1c2fc
--- /dev/null
+++ b/WordPress/Classes/Services/LikeUserHelpers.swift
@@ -0,0 +1,85 @@
+import Foundation
+import CoreData
+
+/// Helper class for creating LikeUser objects.
+/// Used by PostService and CommentService when fetching likes for posts/comments.
+///
+@objc class LikeUserHelper: NSObject {
+
+ @objc class func createOrUpdateFrom(remoteUser: RemoteLikeUser, context: NSManagedObjectContext) -> LikeUser {
+ let liker = likeUser(for: remoteUser, context: context) ?? LikeUser(context: context)
+
+ liker.userID = remoteUser.userID.int64Value
+ liker.username = remoteUser.username
+ liker.displayName = remoteUser.displayName
+ liker.primaryBlogID = remoteUser.primaryBlogID?.int64Value ?? 0
+ liker.avatarUrl = remoteUser.avatarURL
+ liker.bio = remoteUser.bio ?? ""
+ liker.dateLikedString = remoteUser.dateLiked ?? ""
+ liker.dateLiked = DateUtils.date(fromISOString: liker.dateLikedString)
+ liker.likedSiteID = remoteUser.likedSiteID?.int64Value ?? 0
+ liker.likedPostID = remoteUser.likedPostID?.int64Value ?? 0
+ liker.likedCommentID = remoteUser.likedCommentID?.int64Value ?? 0
+ liker.dateFetched = Date()
+
+ updatePreferredBlog(for: liker, with: remoteUser, context: context)
+
+ return liker
+ }
+
+ class func likeUser(for remoteUser: RemoteLikeUser, context: NSManagedObjectContext) -> LikeUser? {
+ let userID = remoteUser.userID ?? 0
+ let siteID = remoteUser.likedSiteID ?? 0
+ let postID = remoteUser.likedPostID ?? 0
+ let commentID = remoteUser.likedCommentID ?? 0
+
+ let request = LikeUser.fetchRequest() as NSFetchRequest
+ request.predicate = NSPredicate(format: "userID = %@ AND likedSiteID = %@ AND likedPostID = %@ AND likedCommentID = %@",
+ userID, siteID, postID, commentID)
+ return try? context.fetch(request).first
+ }
+
+ private class func updatePreferredBlog(for user: LikeUser, with remoteUser: RemoteLikeUser, context: NSManagedObjectContext) {
+ guard let remotePreferredBlog = remoteUser.preferredBlog else {
+ if let existingPreferredBlog = user.preferredBlog {
+ context.deleteObject(existingPreferredBlog)
+ user.preferredBlog = nil
+ }
+
+ return
+ }
+
+ let preferredBlog = user.preferredBlog ?? LikeUserPreferredBlog(context: context)
+
+ preferredBlog.blogUrl = remotePreferredBlog.blogUrl
+ preferredBlog.blogName = remotePreferredBlog.blogName
+ preferredBlog.iconUrl = remotePreferredBlog.iconUrl
+ preferredBlog.blogID = remotePreferredBlog.blogID?.int64Value ?? 0
+ preferredBlog.user = user
+ }
+
+ class func purgeStaleLikes() {
+ ContextManager.shared.performAndSave {
+ purgeStaleLikes(fromContext: $0)
+ }
+ }
+
+ // Delete all LikeUsers that were last fetched at least 7 days ago.
+ private class func purgeStaleLikes(fromContext context: NSManagedObjectContext) {
+ guard let staleDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) else {
+ DDLogError("Error creating date to purge stale Likes.")
+ return
+ }
+
+ let request = LikeUser.fetchRequest() as NSFetchRequest
+ request.predicate = NSPredicate(format: "dateFetched <= %@", staleDate as CVarArg)
+
+ do {
+ let users = try context.fetch(request)
+ users.forEach { context.delete($0) }
+ } catch {
+ DDLogError("Error fetching Like Users: \(error)")
+ }
+ }
+
+}
diff --git a/WordPress/Classes/Services/LocalNewsService.swift b/WordPress/Classes/Services/LocalNewsService.swift
deleted file mode 100644
index 0380545aadbf..000000000000
--- a/WordPress/Classes/Services/LocalNewsService.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-final class LocalNewsService: NewsService {
- private var content: [String: String]?
-
- /// This initialiser is here temporarily. Instead of the content, we should only pass the url to the file that we want to load
- init(filePath: String?) {
- loadFile(path: filePath)
- }
-
- private func loadFile(path: String?) {
- guard let path = path else {
- return
- }
-
- content = NSDictionary.init(contentsOfFile: path) as? [String: String]
- }
-
- func load(then completion: @escaping (Result) -> Void) {
- guard let content = content else {
- let result: Result = .failure(NewsError.fileNotFound)
- completion(result)
-
- return
- }
-
- guard let newsItem = NewsItem(fileContent: content) else {
- let result: Result = .failure(NewsError.invalidContent)
- completion(result)
-
- return
- }
-
- completion(.success(newsItem))
- }
-}
diff --git a/WordPress/Classes/Services/MediaCoordinator.swift b/WordPress/Classes/Services/MediaCoordinator.swift
index fe62cb955e86..5fdd415fa80a 100644
--- a/WordPress/Classes/Services/MediaCoordinator.swift
+++ b/WordPress/Classes/Services/MediaCoordinator.swift
@@ -11,12 +11,18 @@ import enum Alamofire.AFError
class MediaCoordinator: NSObject {
@objc static let shared = MediaCoordinator()
- private(set) var backgroundContext: NSManagedObjectContext = {
- let context = ContextManager.sharedInstance().newDerivedContext()
- context.automaticallyMergesChangesFromParent = true
- return context
+ private let coreDataStack: CoreDataStackSwift
+
+ private var mainContext: NSManagedObjectContext {
+ coreDataStack.mainContext
+ }
+
+ private let syncOperationQueue: OperationQueue = {
+ let queue = OperationQueue()
+ queue.name = "org.wordpress.mediauploadcoordinator.sync"
+ queue.maxConcurrentOperationCount = 1
+ return queue
}()
- private let mainContext = ContextManager.sharedInstance().mainContext
private let queue = DispatchQueue(label: "org.wordpress.mediauploadcoordinator")
@@ -36,8 +42,9 @@ class MediaCoordinator: NSObject {
private let mediaServiceFactory: MediaService.Factory
- init(_ mediaServiceFactory: MediaService.Factory = MediaService.Factory()) {
+ init(_ mediaServiceFactory: MediaService.Factory = MediaService.Factory(), coreDataStack: CoreDataStackSwift = ContextManager.shared) {
self.mediaServiceFactory = mediaServiceFactory
+ self.coreDataStack = coreDataStack
super.init()
@@ -54,12 +61,11 @@ class MediaCoordinator: NSObject {
/// - Returns: `true` if all media in the post is uploading or was uploaded, `false` otherwise.
///
func uploadMedia(for post: AbstractPost, automatedRetry: Bool = false) -> Bool {
- let mediaService = mediaServiceFactory.create(backgroundContext)
let failedMedia: [Media] = post.media.filter({ $0.remoteStatus == .failed })
let mediasToUpload: [Media]
if automatedRetry {
- mediasToUpload = mediaService.failedMediaForUpload(in: post, automatedRetry: automatedRetry)
+ mediasToUpload = Media.failedForUpload(in: post, automatedRetry: automatedRetry)
} else {
mediasToUpload = failedMedia
}
@@ -132,10 +138,8 @@ class MediaCoordinator: NSObject {
/// - parameter blog: The blog that the asset should be added to.
/// - parameter origin: The location in the app where the upload was initiated (optional).
///
- @discardableResult
- func addMedia(from asset: ExportableAsset, to blog: Blog, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? {
- let coordinator = mediaLibraryProgressCoordinator
- return addMedia(from: asset, blog: blog, post: nil, coordinator: coordinator, analyticsInfo: analyticsInfo)
+ func addMedia(from asset: ExportableAsset, to blog: Blog, analyticsInfo: MediaAnalyticsInfo? = nil) {
+ addMedia(from: asset, blog: blog, post: nil, coordinator: mediaLibraryProgressCoordinator, analyticsInfo: analyticsInfo)
}
/// Adds the specified media asset to the specified post. The upload process
@@ -147,57 +151,88 @@ class MediaCoordinator: NSObject {
///
@discardableResult
func addMedia(from asset: ExportableAsset, to post: AbstractPost, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? {
- let coordinator = self.coordinator(for: post)
- return addMedia(from: asset, blog: post.blog, post: post, coordinator: coordinator, analyticsInfo: analyticsInfo)
+ addMedia(from: asset, post: post, coordinator: coordinator(for: post), analyticsInfo: analyticsInfo)
}
- @discardableResult
- private func addMedia(from asset: ExportableAsset, blog: Blog, post: AbstractPost?, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? {
+ /// Create a `Media` instance from the main context and upload the asset to the Meida Library.
+ ///
+ /// - Warning: This function must be called from the main thread.
+ ///
+ /// - SeeAlso: `MediaImportService.createMedia(with:blog:post:thumbnailCallback:completion:)`
+ private func addMedia(from asset: ExportableAsset, post: AbstractPost, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? {
coordinator.track(numberOfItems: 1)
- let service = mediaServiceFactory.create(mainContext)
+ let service = MediaImportService(coreDataStack: coreDataStack)
let totalProgress = Progress.discreteProgress(totalUnitCount: MediaExportProgressUnits.done)
- var creationProgress: Progress? = nil
- let mediaOptional = service.createMedia(with: asset,
- blog: blog,
- post: post,
- progress: &creationProgress,
- thumbnailCallback: { [weak self] media, url in
- self?.thumbnailReady(url: url, for: media)
- },
- completion: { [weak self] media, error in
- guard let strongSelf = self else {
- return
- }
- if let error = error as NSError? {
- if let media = media {
- coordinator.attach(error: error as NSError, toMediaID: media.uploadID)
- strongSelf.fail(error as NSError, media: media)
- } else {
- // If there was an error and we don't have a media object we just say to the coordinator that one item was finished
- coordinator.finishOneItem()
- }
- return
- }
- guard let media = media, !media.isDeleted else {
- return
- }
-
- strongSelf.trackUploadOf(media, analyticsInfo: analyticsInfo)
-
- let uploadProgress = strongSelf.uploadMedia(media)
- totalProgress.addChild(uploadProgress, withPendingUnitCount: MediaExportProgressUnits.threeQuartersDone)
- })
- guard let media = mediaOptional else {
+ let result = service.createMedia(
+ with: asset,
+ blog: post.blog,
+ post: post,
+ thumbnailCallback: { [weak self] media, url in
+ self?.thumbnailReady(url: url, for: media)
+ },
+ completion: { [weak self] media, error in
+ self?.handleMediaImportResult(coordinator: coordinator, totalProgress: totalProgress, analyticsInfo: analyticsInfo, media: media, error: error)
+ }
+ )
+ guard let (media, creationProgress) = result else {
return nil
}
+
processing(media)
- if let creationProgress = creationProgress {
- totalProgress.addChild(creationProgress, withPendingUnitCount: MediaExportProgressUnits.quarterDone)
- coordinator.track(progress: totalProgress, of: media, withIdentifier: media.uploadID)
- }
+
+ totalProgress.addChild(creationProgress, withPendingUnitCount: MediaExportProgressUnits.quarterDone)
+ coordinator.track(progress: totalProgress, of: media, withIdentifier: media.uploadID)
+
return media
}
+ /// Create a `Media` instance and upload the asset to the Meida Library.
+ ///
+ /// - SeeAlso: `MediaImportService.createMedia(with:blog:post:receiveUpdate:thumbnailCallback:completion:)`
+ private func addMedia(from asset: ExportableAsset, blog: Blog, post: AbstractPost?, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) {
+ coordinator.track(numberOfItems: 1)
+ let service = MediaImportService(coreDataStack: coreDataStack)
+ let totalProgress = Progress.discreteProgress(totalUnitCount: MediaExportProgressUnits.done)
+ let creationProgress = service.createMedia(
+ with: asset,
+ blog: blog,
+ post: post,
+ receiveUpdate: { [weak self] media in
+ self?.processing(media)
+ coordinator.track(progress: totalProgress, of: media, withIdentifier: media.uploadID)
+ },
+ thumbnailCallback: { [weak self] media, url in
+ self?.thumbnailReady(url: url, for: media)
+ },
+ completion: { [weak self] media, error in
+ self?.handleMediaImportResult(coordinator: coordinator, totalProgress: totalProgress, analyticsInfo: analyticsInfo, media: media, error: error)
+ }
+ )
+
+ totalProgress.addChild(creationProgress, withPendingUnitCount: MediaExportProgressUnits.quarterDone)
+ }
+
+ private func handleMediaImportResult(coordinator: MediaProgressCoordinator, totalProgress: Progress, analyticsInfo: MediaAnalyticsInfo?, media: Media?, error: Error?) -> Void {
+ if let error = error as NSError? {
+ if let media = media {
+ coordinator.attach(error: error as NSError, toMediaID: media.uploadID)
+ fail(error as NSError, media: media)
+ } else {
+ // If there was an error and we don't have a media object we just say to the coordinator that one item was finished
+ coordinator.finishOneItem()
+ }
+ return
+ }
+ guard let media = media, !media.isDeleted else {
+ return
+ }
+
+ trackUploadOf(media, analyticsInfo: analyticsInfo)
+
+ let uploadProgress = uploadMedia(media)
+ totalProgress.addChild(uploadProgress, withPendingUnitCount: MediaExportProgressUnits.threeQuartersDone)
+ }
+
/// Retry the upload of a media object that previously has failed.
///
/// - Parameters:
@@ -286,42 +321,77 @@ class MediaCoordinator: NSObject {
func delete(media: [Media], onProgress: ((Progress?) -> Void)? = nil, success: (() -> Void)? = nil, failure: (() -> Void)? = nil) {
media.forEach({ self.cancelUpload(of: $0) })
- let service = mediaServiceFactory.create(backgroundContext)
- service.deleteMedia(media,
- progress: { onProgress?($0) },
- success: success,
- failure: failure)
+ coreDataStack.performAndSave { context in
+ let service = self.mediaServiceFactory.create(context)
+ service.deleteMedia(media,
+ progress: { onProgress?($0) },
+ success: success,
+ failure: failure)
+ }
}
@discardableResult
private func uploadMedia(_ media: Media, automatedRetry: Bool = false) -> Progress {
- let service = mediaServiceFactory.create(backgroundContext)
+ let resultProgress = Progress.discreteProgress(totalUnitCount: 100)
- var progress: Progress? = nil
+ let success: () -> Void = {
+ self.end(media)
+ }
+ let failure: (Error?) -> Void = { error in
+ // Ideally the upload service should always return an error. This may be easier to enforce
+ // if we update the service to Swift, but in the meanwhile I'm instantiating an unknown upload
+ // error whenever the service doesn't provide one.
+ //
+ let nserror = error as NSError?
+ ?? NSError(
+ domain: MediaServiceErrorDomain,
+ code: MediaServiceError.unknownUploadError.rawValue,
+ userInfo: [
+ "filename": media.filename ?? "",
+ "filesize": media.filesize ?? "",
+ "height": media.height ?? "",
+ "width": media.width ?? "",
+ "localURL": media.localURL ?? "",
+ "remoteURL": media.remoteURL ?? "",
+ ])
- service.uploadMedia(media,
- automatedRetry: automatedRetry,
- progress: &progress,
- success: {
- self.end(media)
- }, failure: { error in
- guard let nserror = error as NSError? else {
- return
- }
self.coordinator(for: media).attach(error: nserror, toMediaID: media.uploadID)
self.fail(nserror, media: media)
- })
- var resultProgress = Progress.discreteCompletedProgress()
- if let taskProgress = progress {
- resultProgress = taskProgress
}
+
+ // For some reason, this `MediaService` instance has to be created with the main context, otherwise
+ // the successfully uploaded media is shown as a "local" assets incorrectly (see the issue comment linked below).
+ // https://github.com/wordpress-mobile/WordPress-iOS/issues/20298#issuecomment-1465319707
+ let service = self.mediaServiceFactory.create(coreDataStack.mainContext)
+ var progress: Progress? = nil
+ service.uploadMedia(media, automatedRetry: automatedRetry, progress: &progress, success: success, failure: failure)
+ if let progress {
+ resultProgress.addChild(progress, withPendingUnitCount: resultProgress.totalUnitCount)
+ }
+
uploading(media, progress: resultProgress)
+
return resultProgress
}
private func trackUploadOf(_ media: Media, analyticsInfo: MediaAnalyticsInfo?) {
+ guard let info = analyticsInfo else {
+ return
+ }
+
+ guard let event = info.eventForMediaType(media.mediaType) else {
+ // Fall back to the WPShared event tracking
+ trackUploadViaWPSharedOf(media, analyticsInfo: analyticsInfo)
+ return
+ }
+
+ let properties = info.properties(for: media)
+ WPAnalytics.track(event, properties: properties, blog: media.blog)
+ }
+
+ private func trackUploadViaWPSharedOf(_ media: Media, analyticsInfo: MediaAnalyticsInfo?) {
guard let info = analyticsInfo,
- let event = info.eventForMediaType(media.mediaType) else {
+ let event = info.wpsharedEventForMediaType(media.mediaType) else {
return
}
@@ -430,7 +500,10 @@ class MediaCoordinator: NSObject {
func addObserver(_ onUpdate: @escaping ObserverBlock, for media: Media? = nil) -> UUID {
let uuid = UUID()
- let observer = MediaObserver(media: media, onUpdate: onUpdate)
+ let observer = MediaObserver(
+ subject: media.flatMap({ .media(id: $0.objectID) }) ?? .all,
+ onUpdate: onUpdate
+ )
queue.async {
self.mediaObservers[uuid] = observer
@@ -454,7 +527,7 @@ class MediaCoordinator: NSObject {
let uuid = UUID()
let original = post.original ?? post
- let observer = MediaObserver(post: original, onUpdate: onUpdate)
+ let observer = MediaObserver(subject: .post(id: original.objectID), onUpdate: onUpdate)
queue.async {
self.mediaObservers[uuid] = observer
@@ -503,42 +576,28 @@ class MediaCoordinator: NSObject {
}
/// Encapsulates an observer block and an optional observed media item or post.
- struct MediaObserver {
- let media: Media?
- let post: AbstractPost?
- let onUpdate: ObserverBlock
-
- init(onUpdate: @escaping ObserverBlock) {
- self.media = nil
- self.post = nil
- self.onUpdate = onUpdate
+ private struct MediaObserver {
+ enum Subject: Equatable {
+ case media(id: NSManagedObjectID)
+ case post(id: NSManagedObjectID)
+ case all
}
- init(media: Media?, onUpdate: @escaping ObserverBlock) {
- self.media = media
- self.post = nil
- self.onUpdate = onUpdate
- }
-
- init(post: AbstractPost, onUpdate: @escaping ObserverBlock) {
- self.media = nil
- self.post = post
- self.onUpdate = onUpdate
- }
+ let subject: Subject
+ let onUpdate: ObserverBlock
}
- /// Utility method to return all observers for a specific media item,
- /// including any 'wildcard' observers that are observing _all_ media items.
+ /// Utility method to return all observers for a `Media` item with the given `NSManagedObjectID`
+ /// and part of the posts with given `NSManagedObjectID`s, including any 'wildcard' observers
+ /// that are observing _all_ media items.
///
- private func observersForMedia(_ media: Media) -> [MediaObserver] {
- let mediaObservers = self.mediaObservers.values.filter({ $0.media?.uploadID == media.uploadID })
+ private func observersForMedia(withObjectID mediaObjectID: NSManagedObjectID, originalPostIDs: [NSManagedObjectID]) -> [MediaObserver] {
+ let mediaObservers = self.mediaObservers.values.filter({ $0.subject == .media(id: mediaObjectID) })
let postObservers = self.mediaObservers.values.filter({
- guard let posts = media.posts as? Set