├── .appcast.xml
├── .eslintrc.js
├── .gitignore
├── LICENSE
├── README.md
├── assets
├── icon.png
└── placeholder.png
├── docs
├── unsplash-screenshot-001.png
├── unsplash-screenshot-002.png
└── unsplash-screenshot-003.png
├── package-lock.json
├── package.json
└── src
├── DataProvider.js
├── manifest.json
└── unsplash.js
/.appcast.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
5 |
6 |
7 | -
8 |
9 |
10 | -
11 |
12 |
13 | -
14 |
15 |
16 | -
17 |
18 |
19 | -
20 |
21 |
22 | -
23 |
24 |
25 | -
26 |
27 |
28 | -
29 |
30 |
31 | -
32 |
33 |
34 | -
35 |
36 |
37 | -
38 |
39 |
40 | -
41 |
42 |
43 | -
44 |
45 |
46 | -
47 |
48 |
49 | -
50 |
51 |
52 | -
53 |
54 |
55 | -
56 |
57 |
58 | -
59 |
60 |
61 | -
62 |
63 |
64 | -
65 |
66 |
67 | -
68 |
69 |
70 | -
71 |
72 |
73 | -
74 |
75 |
76 | -
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "extends": "standard",
3 | "globals": {
4 | "context": false,
5 | "nil": false,
6 | "NSURL": false,
7 | "NSURLRequest": false,
8 | "fetch": false,
9 | "NSProcessInfo": false,
10 | "NSFileManager": false,
11 | "NSURLConnection": false,
12 | "NSTemporaryDirectory": false,
13 | "MSImageData": false,
14 | "NSImage": false,
15 | "error": true,
16 | "NSWorkspace": false
17 | }
18 | };
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build artifacts
2 | unsplash.sketchplugin
3 | *.zip
4 |
5 | # npm
6 | node_modules
7 | .npm
8 | npm-debug.log
9 |
10 | # mac
11 | .DS_Store
12 |
13 | # WebStorm
14 | .idea
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018-2019 Bohemian Coding
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Unsplash Data Plugin
2 |
3 | ## Installation
4 |
5 | The plugin comes bundled with Sketch since version 52, but if for some reason you’ve lost it, you can [download it from the releases page](https://github.com/sketch-hq/unsplash-sketchplugin/releases/latest).
6 |
7 | ## Features
8 |
9 | Get images from Unsplash, using Sketch 52’s new Data Supplier feature.
10 |
11 | You can use it from the toolbar Data icon…
12 |
13 | 
14 |
15 | …or from the contextual menu for any layer…
16 |
17 | 
18 |
19 | …or even for Overrides using the Inspector:
20 |
21 | 
22 |
23 | When you’ve set an image fill for a layer, you can later on open the original URL for an image, in case you need to full credits, or download the original file, etc. To do that, click Plugins › Unsplash › View Photo on Unsplash. It does not work for overrides yet, but we’re working on it!
24 |
25 | ## Use specific photo
26 |
27 | If you don't want a random photo, but a specific one, you can do a search in unsplash.com and then use the URL for the image you want as the input.
28 |
29 | To do that, select the "Search Photo…" option then in the input field paste in an Unsplash photo URL. If you'd rather use the ID of the image, you can do that by entering `id:photo_id` as the search term.
30 |
31 | (Thanks for this feature :)
32 |
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/assets/icon.png
--------------------------------------------------------------------------------
/assets/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/assets/placeholder.png
--------------------------------------------------------------------------------
/docs/unsplash-screenshot-001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/docs/unsplash-screenshot-001.png
--------------------------------------------------------------------------------
/docs/unsplash-screenshot-002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/docs/unsplash-screenshot-002.png
--------------------------------------------------------------------------------
/docs/unsplash-screenshot-003.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/docs/unsplash-screenshot-003.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "unsplash",
3 | "version": "1.1.1",
4 | "engines": {
5 | "sketch": ">=3.0"
6 | },
7 | "skpm": {
8 | "manifest": "src/manifest.json",
9 | "main": "unsplash.sketchplugin",
10 | "assets": [
11 | "assets/**/*"
12 | ]
13 | },
14 | "scripts": {
15 | "build": "skpm-build",
16 | "watch": "skpm-build --watch",
17 | "start": "skpm-build --watch --run",
18 | "postinstall": "npm run build && skpm-link"
19 | },
20 | "devDependencies": {
21 | "@skpm/builder": "^0.7.7",
22 | "eslint": "^6.5.1",
23 | "eslint-config-standard": "^14.1.1",
24 | "eslint-plugin-import": "^2.20.2",
25 | "eslint-plugin-node": "^11.1.0",
26 | "eslint-plugin-promise": "^4.2.1",
27 | "eslint-plugin-standard": "^4.0.1",
28 | "lodash": "^4.17.21",
29 | "serialize-javascript": ">=3.1.0"
30 | },
31 | "author": "Sketch",
32 | "repository": "https://github.com/sketch-hq/unsplash-sketchplugin.git",
33 | "description": "Easily grab images from Unsplash",
34 | "license": "MIT",
35 | "dependencies": {
36 | "@skpm/buffer": "^0.1.4",
37 | "@skpm/fs": "^0.2.6",
38 | "sketch-image-downloader": "^1.0.1"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/DataProvider.js:
--------------------------------------------------------------------------------
1 | const os = require('os')
2 | const path = require('path')
3 | const util = require('util')
4 | const fs = require('@skpm/fs')
5 | const sketch = require('sketch')
6 | const { getImagesURLsForItems } = require('./unsplash')
7 |
8 | const { DataSupplier, UI, Settings } = sketch
9 |
10 | const { insertImage, getImageFromURL } = require('sketch-image-downloader')
11 |
12 | const SETTING_KEY = 'unsplash.photo.id'
13 | const FOLDER = path.join(os.tmpdir(), 'com.sketchapp.unsplash-plugin')
14 |
15 | export function onStartup () {
16 | DataSupplier.registerDataSupplier('public.image', 'Random Photo', 'SupplyRandomPhoto')
17 | DataSupplier.registerDataSupplier('public.image', 'Search Photo…', 'SearchPhoto')
18 | }
19 |
20 | export function onShutdown () {
21 | DataSupplier.deregisterDataSuppliers()
22 | try {
23 | if (fs.existsSync(FOLDER)) {
24 | fs.rmdirSync(FOLDER)
25 | }
26 | } catch (err) {
27 | console.error(err)
28 | }
29 | }
30 |
31 | export function onSupplyRandomPhoto (context) {
32 | setImageForContext(context)
33 | }
34 |
35 | function containsPhotoId (searchTerm) {
36 | return searchTerm.substr(0, 3) === 'id:' || searchTerm.indexOf('unsplash.com/photos/') !== -1
37 | }
38 |
39 | function extractPhotoId (searchTerm) {
40 | if (searchTerm.substr(0, 3) === 'id:') {
41 | return searchTerm.substr(3)
42 | }
43 |
44 | // Extract photoId from a "unsplash.com/photos/" URL
45 | // Allows a URL with or without http/https
46 | // It also strips out anything after the photoId
47 | let photoId = searchTerm.substr(searchTerm.indexOf('unsplash.com/photos/') + 20)
48 | const artifactLocation = photoId.search(/[^a-z0-9_-]/i)
49 | return artifactLocation !== -1 ? photoId.substr(0, artifactLocation) : photoId
50 | }
51 |
52 | export function onSearchPhoto (context) {
53 | // 21123: retrieve previous search term. If multiple layers are selected, find the first search term
54 | // in the group…
55 | let selectedLayers = sketch.getSelectedDocument().selectedLayers.layers
56 | let previousTerms = selectedLayers.map(layer => Settings.layerSettingForKey(layer, 'unsplash.search.term'))
57 | let firstPreviousTerm = previousTerms.find(term => term !== undefined)
58 | let previousTerm = firstPreviousTerm || 'People'
59 | // TODO: support multiple selected layers with different search terms for each
60 | if (sketch.version.sketch < 53) {
61 | const searchTerm = UI.getStringFromUser('Search Unsplash for…', previousTerm).trim()
62 | if (searchTerm !== 'null') {
63 | selectedLayers.forEach(layer => {
64 | Settings.setLayerSettingForKey(layer, 'unsplash.search.term', searchTerm)
65 | })
66 | searchTerm = encodeURI(searchTerm)
67 | if (containsPhotoId(searchTerm)) {
68 | setImageForContext(context, null, extractPhotoId(searchTerm))
69 | } else {
70 | setImageForContext(context, searchTerm.replace(/\s+/g, '-').toLowerCase())
71 | }
72 | }
73 | } else {
74 | UI.getInputFromUser('Search Unsplash for…',
75 | { initialValue: previousTerm },
76 | (err, searchTerm) => {
77 | if (err) { return } // user hit cancel
78 | if ((searchTerm = searchTerm.trim()) !== 'null') {
79 | selectedLayers.forEach(layer => {
80 | Settings.setLayerSettingForKey(layer, 'unsplash.search.term', searchTerm)
81 | })
82 | searchTerm = encodeURI(searchTerm)
83 | if (containsPhotoId(searchTerm)) {
84 | setImageForContext(context, null, extractPhotoId(searchTerm))
85 | } else {
86 | setImageForContext(context, searchTerm.replace(/\s+/g, '-').toLowerCase())
87 | }
88 | }
89 | }
90 | )
91 | }
92 | }
93 |
94 | export default function onImageDetails () {
95 | const selectedDocument = sketch.getSelectedDocument()
96 | const selection = selectedDocument ? selectedDocument.selectedLayers : []
97 | if (selection.length > 0) {
98 | selection.forEach(element => {
99 | const id = Settings.layerSettingForKey(element, SETTING_KEY) || (
100 | element.type === 'SymbolInstance' &&
101 | element.overrides
102 | .map(o => Settings.layerSettingForKey(o, SETTING_KEY))
103 | .find(s => !!s)
104 | )
105 | if (id) {
106 | NSWorkspace.sharedWorkspace().openURL(NSURL.URLWithString(`https://unsplash.com/photos/${id}`))
107 | } else {
108 | // This layer doesn't have an Unsplash photo set, do nothing.
109 | // Alternatively, show an explanation of what the user needs to do to make this work…
110 | UI.message(`To get a random photo, click Data › Unsplash › Random Photo in the toolbar, or right click the layer › Data Feeds › Unsplash › Random Photo`)
111 | }
112 | })
113 | } else {
114 | UI.message(`Please select at least one layer`)
115 | }
116 | }
117 |
118 | function setImageForContext (context, searchTerm, photoId) {
119 | const dataKey = context.data.key
120 | const items = util.toArray(context.data.items).map(sketch.fromNative)
121 |
122 | UI.message('🕑 Downloading…')
123 | getImagesURLsForItems(items, { searchTerm, photoId })
124 | .then(res => Promise.all(res.map(({ data, item, index, frame, error }) => {
125 | if (error) {
126 | UI.message(error)
127 | console.error(error)
128 | } else {
129 | process(data, dataKey, index, item, frame)
130 | }
131 | })))
132 | .catch(e => {
133 | UI.message(e)
134 | console.error(e)
135 | })
136 | }
137 |
138 | function process (data, dataKey, index, item, frame) {
139 | // supply the data
140 | let url = `${data.urls.full}&fit=min&w=${frame.width*2}&h=${frame.height*2}`
141 | return getImageFromURL(url).then(imagePath => {
142 | if (!imagePath) {
143 | // TODO: something wrong happened, show something to the user
144 | return
145 | }
146 | DataSupplier.supplyDataAtIndex(dataKey, imagePath, index)
147 |
148 | // store where the image comes from, but only if this is a regular layer
149 | if (item.type !== 'DataOverride') {
150 | Settings.setLayerSettingForKey(item, SETTING_KEY, data.id)
151 | }
152 |
153 | UI.message('📷 by ' + data.user.name + ' on Unsplash')
154 | })
155 | }
156 |
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Unsplash",
3 | "compatibleVersion": 3,
4 | "bundleVersion": 1,
5 | "icon": "icon.png",
6 | "suppliesData" : true,
7 | "commands": [
8 | {
9 | "name": "View Photo on Unsplash",
10 | "identifier": "imageDetails",
11 | "script": "DataProvider.js",
12 | "handler": {
13 | "run": "onImageDetails"
14 | }
15 | },
16 | {
17 | "script" : "DataProvider.js",
18 | "handlers" : {
19 | "actions" : {
20 | "Startup" : "onStartup",
21 | "Shutdown" : "onShutdown",
22 | "SupplyRandomPhoto" : "onSupplyRandomPhoto",
23 | "SearchPhoto" : "onSearchPhoto"
24 | }
25 | }
26 | }
27 | ],
28 | "menu": {
29 | "title": "Unsplash",
30 | "items": [
31 | "imageDetails"
32 | ]
33 | }
34 | }
--------------------------------------------------------------------------------
/src/unsplash.js:
--------------------------------------------------------------------------------
1 | /* globals CGPathGetBoundingBox */
2 | const sketch = require('sketch')
3 | const { toArray } = require('util')
4 | const API_KEY = 'bfd993ac8c14516588069b3fc664b216d0e20fb9b9fa35aa06fcc3ba6e0bc703'
5 | const API_ENDPOINT = 'https://api.unsplash.com'
6 | const apiOptions = {
7 | 'headers': {
8 | 'app-pragma': 'no-cache'
9 | }
10 | }
11 |
12 | function flatten (arrays) {
13 | return arrays.reduce((prev, array) => prev.concat(array), [])
14 | }
15 |
16 | export function getImagesURLsForItems (items, { searchTerm, photoId }) {
17 | const orientations = items.reduce((prev, item, index) => {
18 | if (!item.type) {
19 | // if we get an unknown item, it means that we have a layer that is not yet
20 | // recognized by the API (probably an MSOvalShape or something)
21 | // force cast it to a Shape
22 | item = sketch.Shape.fromNative(item.sketchObject)
23 | }
24 | let layer
25 | if (item.type === 'DataOverride') {
26 | // only available on Sketch 54+
27 | const overrideFrame = item.override.getFrame && item.override.getFrame()
28 | if (overrideFrame) {
29 | layer = {
30 | frame: overrideFrame
31 | }
32 | } else {
33 | const overrideRepresentation = toArray(
34 | item.symbolInstance.sketchObject.overrideContainer().flattenedChildren()
35 | ).find(
36 | // eslint-disable-next-line eqeqeq
37 | x => x.availableOverride() == item.override.sketchObject
38 | )
39 | if (!overrideRepresentation) {
40 | layer = item.symbolInstance
41 | } else {
42 | const path = overrideRepresentation.pathInInstance()
43 | const bounds = CGPathGetBoundingBox(path)
44 | layer = {
45 | frame: {
46 | width: Number(bounds.size.width),
47 | height: Number(bounds.size.height)
48 | }
49 | }
50 | }
51 | }
52 | } else {
53 | layer = item
54 | }
55 |
56 | if (layer.frame.width > layer.frame.height) {
57 | prev.landscape.push({ item, index, frame: layer.frame })
58 | } else if (layer.frame.width < layer.frame.height) {
59 | prev.portrait.push({ item, index, frame: layer.frame })
60 | } else if (layer.frame.width === layer.frame.height) {
61 | prev.squarish.push({ item, index, frame: layer.frame })
62 | }
63 | return prev
64 | }, {
65 | landscape: [],
66 | portrait: [],
67 | squarish: []
68 | })
69 |
70 | let action = photoId ? `/photos/${photoId}` : '/photos/random'
71 | let url = API_ENDPOINT + action + '?client_id=' + API_KEY
72 |
73 | if (photoId) {
74 | return fetch(url, apiOptions)
75 | .then(response => response.json())
76 | .then(json => {
77 | if (json.errors) {
78 | return Promise.reject(json.errors[0])
79 | }
80 | json = new Array(items.length).fill(json)
81 | return json.map((data, j) => ({
82 | data,
83 | ...flatten(Object.values(orientations))[j]
84 | }))
85 | }).catch(error => {
86 | return [{ error }]
87 | })
88 | }
89 |
90 | if (searchTerm) {
91 | url += '&query=' + searchTerm
92 | }
93 |
94 |
95 | return Promise.all(Object.keys(orientations).map(orientation => {
96 | const itemsForOrientation = orientations[orientation]
97 | if (!itemsForOrientation || !itemsForOrientation.length) {
98 | return Promise.resolve([])
99 | }
100 |
101 | // we can only request 30 photos max at a time
102 | // (from https://unsplash.com/documentation#pagination)
103 | const numberOfRequests = Math.ceil(itemsForOrientation.length / 30)
104 |
105 | return Promise.all(Array(numberOfRequests).fill().map((_, i) => {
106 | // we only itemsForOrientation % 30 photos on the last request
107 | const count = i === numberOfRequests - 1 ? (itemsForOrientation.length % 30) : 30
108 |
109 | return fetch(`${url}&count=${count}&orientation=${orientation}`, apiOptions)
110 | .then(response => response.json())
111 | .then(json => {
112 | if (json.errors) {
113 | return Promise.reject(json.errors[0])
114 | }
115 | return json.map((data, j) => ({
116 | data,
117 | ...itemsForOrientation[30 * i + j]
118 | }))
119 | }).catch(error => {
120 | // don't reject the promise here so that we can
121 | // at least can provide data for the others
122 | return [{ error }]
123 | })
124 | })).then(flatten)
125 | })).then(flatten)
126 | }
127 |
--------------------------------------------------------------------------------