Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support setting Alfred variables with alfy.output (#43) #44

Closed

Conversation

luca-aurelia
Copy link

Linter and tests (including new ones) are all green. Also updated the readme. I tried to match the existing line usage and code organization. I'd love feedback. I'm just getting started with open source contributions, and I know there's plenty to learn.

@SamVerschueren
Copy link
Collaborator

Hi @noise-machines, thank you for the PR! Really appreciate new contributions :).

I tried this out first by looking how you pass down environment variables. It doesn't look like what you're trying to do is supported. This is what I tried and it looks like you are trying to do the same thing

alfy.output([
    title: 'Hello World',
    arg: JSON.stringify({arg: 'foo', variables: {foo: 'bar'}})
]);

I executed this but the only thing I get is just the stringified object as argument. It's not being parsed.

So I dived into the documentation and found this page and more specifically, the Variables / Session Variables section. It just mentions variables as a global output but not on a per item basis. Do you happen to have proof that this should work?

@luca-aurelia
Copy link
Author

Hi @SamVerschueren,

Thanks for the feedback! I discovered this feature through this excellent forum post. The trick is to stringify an object that has an alfredworkflow key. It's easy to miss, but I do that here in my PR. Your code will work if you change it to:

alfy.output([{
  title: 'Hello World',
  arg: JSON.stringify({
    alfredworkflow: { arg: 'foo', variables: { foo: 'bar' } }
  })
}]);

Here's a demo of it working on my machine with Alfred 3.3.1:

alfy-test

If you think it'd be helpful, I can update my PR to include references to that forum post where appropriate.

@SamVerschueren
Copy link
Collaborator

Aha, alright. Let me check it later today! Thanks again for working on it, it's pretty cool stuff.

Copy link
Collaborator

@SamVerschueren SamVerschueren left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good! Works great and I actually need this in one of my workflows, didn't know it was possible :).

Added some comments, just push the changes on the same branch and it will automatically pick it up.

index.js Outdated
};

alfy.output = items => {
if (isDefined(items) && items.forEach) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just check if items is an array, otherwise throw a TypeError

if (!Array.isArray(items)) {
    throw new TypeError(`Expected \`items\` to be of type \`Array\`, got \`${typeof items}\``);
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a breaking change to the output API, right?

Old implementation:

alfy.output('hi!'); // no problem

New implementation:

alfy.output('hi!'); // TypeError

I'm happy to implement it, just want to confirm.

P.S. - does Alfy follow SemVer?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, I've just changed it to this:

  if (Array.isArray(items)) {
    items = items.map(format);
  }

Let me know if you'd prefer the breaking change.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was always documented as accepting an Array. Enforcing the documented behavior is not a breaking change.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I don't think Alfred accepts anything else except an Array of items. This means that we are now just more verbose to what it accepts.

readme.md Outdated
Using the `variables` property:

```js
alfy.output([{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put the objects on newlines

alfy.output([
    {
        title: 'Unicorn',
        arg: '🦄',
        variables: {
            color: 'white'
        }
    },
    {
        //...
    }
]);

readme.md Outdated
//=> 'myriad' if they selected Rainbow
```

Alfred Workflow Variables are also available in the workflow editor using the form `{var:varname}`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alfred Workflow Variables are also available in the workflow editor using the form {var:varname}.

Can you add an example for the color case above? Would {myColor:color} work for instance?

if (isDefined(items) && items.forEach) {
items.forEach(formatItemArg);
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use items.map

items = items.map(item => format(item));

And then return the item in the format function.

index.js Outdated
const isDefined = x => x !== null && x !== undefined;

const formatItemArg = item => {
if (isDefined(item) && isDefined(item.variables)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has to be expanded. It's also possible to provide variables for mods like this

alfy.output([
    {
        title: 'Unicorn',
        arg: '🦄',
        variables: {
            color: 'white'
        },
        mods: {
            alt: {
                title: 'Rainbow',
                arg: '🌈',
                variables: {
                    color: 'red'
                }
            }
        }
    }
]);

If you press the alt key, it will now show the Rainbow instead.

test/output.js Outdated
@@ -0,0 +1,76 @@
import test from 'ava';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be import {serial as test} from 'ava'; to make sure it runs all the tests serially. Otherwise the output could be mangled. It's weird that it works right now to be honest.

- handle mod keys
- implement tests for mod keys
- check if items is an array instead of checking existence of
  items.forEach
- change items.forEach to items.map
- fix object formatting in readme
- add example of referencing an environment variable in the workflow
  editor
- run tests serially
@luca-aurelia
Copy link
Author

luca-aurelia commented Mar 9, 2017

Thanks for the excellent and in-depth feedback, @SamVerschueren! I believe I got all your requested changes. Let me know if there's anything else I can do.

Copy link
Collaborator

@SamVerschueren SamVerschueren left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for all the comments. I like to review in multiple phases :).

Feel free to discuss some of my comments. Maybe you did some things because of a reason. Don't be afraid to say so.

I think we should also put those new format function in a lib/util.js file.

I was wondering earlier today if we should call it env instead of variables. Any suggestions or feedback on this?

// @sindresorhus

index.js Outdated

alfy.output = items => {
if (Array.isArray(items)) {
items = items.map(format);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to use items.map(item => format(item)). The reason for this is that the map callback returns multiple parameters. This results in sending all those parameters to format which may result in bugs in the future if for instance format starts excepting additional input arguments.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, didn't think about that! Good point. Fixed 😄

readme.md Outdated
@@ -179,6 +179,40 @@ alfy.output([{

<img src="media/screenshot-output.png" width="694">

Using the `variables` property:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should put this under it's own section so we can easily link it from the highlights and to make it more clear. Not sure where though, any ideas @sindresorhus? Maybe a new section Environment variables under Usage or Example? Or on the same level as those?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new top-level section called "Environment variables".

index.js Outdated
};

alfy.output = items => {
if (Array.isArray(items)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To come back to the throw new TypeError() thing. Just wondering, is there a reason why you would use alfy.output without providing an Array? If not, I think I like to be more explicit about it and throw an error instead.

// @sindresorhus

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a little more research into this question, and I agree with you about throwing the TypeError, @SamVerschueren. I was misremembering the previous behavior of alfy.output. It already assumes you've passed an Array:

// previous implementation
alfy.output = arr => {
  console.log(JSON.stringify({items: arr}, null, '\t'));
};

It's true that the previous implementation wouldn't throw a TypeError, but I think the error is actually better. It prevents people from doing stuff like

alfy.output('hi!');
// outputs '{"items":"hi!"}'

which doesn't make sense.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If @sindresorhus agrees, let's move forward with throwing a TypeError.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, should throw a TypeError.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

index.js Outdated
// see https://www.alfredforum.com/topic/9070-how-to-workflowenvironment-variables/
// for documentation on setting environment variables from Alfred workflows
const format = item => {
if (!isDefined(item)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if item is not defined, we should make it an empty object instead.

item = item || {};

And just continue. This will make sure that we don't output null or undefined to Alfred which will probably result in a crash.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried outputting an array of nulls and noticed something pretty interesting. If you're calling your script from a script filter, Alfred just hangs when you output an array of non-objects.

// Example 1: Alfred hangs when this is called from a script filter
alfy.output(['hi', 'hey']);

// Example 2: same for this
alfy.output([null, null, undefined]);

When called from a Run Script action, you just get stringified JSON as your Alfred {query}:

'{
  "items": [
    null,
    null
  ]
}'

How does that influence our decision here? Not quite sure. If we use

item = item || {};

that will protect users from example 2 above, but not from example 1. What do you think?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just throw if it's a non-object. Better to fail loudly than being silently "smart".

Could use: https://github.com/sindresorhus/is-plain-obj

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

index.js Outdated
return item;
}

if (isDefined(item.variables)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just write if (item.variables) {

index.js Outdated
item = wrapArg(item);
}

if (isDefined(item.mods)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (item.mods) {

index.js Outdated
};

const formatMods = item => {
const copy = Object.assign({}, item);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to copy it first. It doesn't really matter in this case if we alter the object or not.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

index.js Outdated
const formatMods = item => {
const copy = Object.assign({}, item);

Object.keys(item.mods).forEach(modKey => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are more fan of for...of :)

for (const mod of Object.keys(item.mods)) {
    item.mods[mod] = wrapArg(item.mods[mod]);
}

return item;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! That syntax looks a little nicer to me too. I was wondering myself whether to go with the loop body you suggested.

Done 😄

index.js Outdated
const wrapArg = item => {
const alfredworkflow = {arg: item.arg, variables: item.variables};
const arg = JSON.stringify({alfredworkflow});
const newItem = Object.assign({}, item, {arg});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just put argument on the item object directly. No need to copy it I guess.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your reasoning. But I copied the item because I wanted to avoid this kind of scenario:

const unicorn = {
  title: 'unicorn',
  arg: '🦄',
  variables: {
    selectedMagicalBeing: true
  }
};

alfy.output([unicorn]);

const unicornEmoji = unicorn.arg;
doSomething(unicornEmoji);
// weird behavior. unicornEmoji is now a stringified JSON object

Obviously, you can avoid this sort of problem by calling alfy.output last. And I'm sure that's how most people use it. I just don't want Alfy users to have to remember to call alfy.output last.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General rule is to always use Object.assign if you modify a user-specified object. Not doing so can cause subtle bugs.

test/output.js Outdated
t.notThrows(() => m.output(undefined));
});

const itemWithMod = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this at the top.

@luca-aurelia
Copy link
Author

I like calling it env instead of variables. You use env when accessing the environment variables, so you should use env when setting them.

@SamVerschueren
Copy link
Collaborator

Yes to me it sounds much better then variables. Don't like to decide this alone though, like to have @sindresorhus his opinion as well.

index.js Outdated
const wrapArg = item => {
const alfredworkflow = {arg: item.arg, variables: item.variables};
const arg = JSON.stringify({alfredworkflow});
const newItem = Object.assign({}, item, {arg});
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General rule is to always use Object.assign if you modify a user-specified object. Not doing so can cause subtle bugs.

index.js Outdated
};

const formatMods = item => {
const copy = Object.assign({}, item);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

index.js Outdated
// see https://www.alfredforum.com/topic/9070-how-to-workflowenvironment-variables/
// for documentation on setting environment variables from Alfred workflows
const format = item => {
if (!isDefined(item)) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just throw if it's a non-object. Better to fail loudly than being silently "smart".

Could use: https://github.com/sindresorhus/is-plain-obj

readme.md Outdated
@@ -179,6 +179,40 @@ alfy.output([{

<img src="media/screenshot-output.png" width="694">

Using the `variables` property:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new top-level section called "Environment variables".

readme.md Outdated

```js
// After a user selects "Unicorn" or "Rainbow"
process.env.color
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we alias this as alfy.env?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that would be nice.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@sindresorhus
Copy link
Owner

Yes, let's call it env and not variables.

@SamVerschueren
Copy link
Collaborator

@noise-machines ping :)

@luca-aurelia
Copy link
Author

Thanks for the ping @SamVerschueren. Will implement the desired changes this week. :)

@SamVerschueren
Copy link
Collaborator

@noise-machines Sorry for another ping :p. If you don't find the time to finish this, give me write access to your repository and I might be able to do this for you. Or if you want to do it yourself, feel free :).

@luca-aurelia
Copy link
Author

@SamVerschueren, no worries! If I don't get to it in the next few days, I'll give you write access.

@sindresorhus
Copy link
Owner

@noise-machines Interested in finishing this up? If not, could you give write-access to me and Sam?

@luca-aurelia
Copy link
Author

Okay, I believe all requested changes have been implemented. Thanks for your patience and excellent feedback, @SamVerschueren and @sindresorhus.

Copy link
Collaborator

@SamVerschueren SamVerschueren left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late reply, forgot about this PR. Let's get it merged :).

Have some minor improvements. I also think we should extract the format (+ all the other extra methods) to a lib/utils.js file.

index.js Outdated
// for documentation on setting environment variables from Alfred workflows.
const format = item => {
if (!isPlainObj(item)) {
throw new TypeError(`Expected \`item\` to be a plain object, got ${typeof item}.`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap ${typeof item} between backticks as well.

index.js Outdated
};

// See https://www.alfredforum.com/topic/9070-how-to-workflowenvironment-variables/
// for documentation on setting environment variables from Alfred workflows.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put the comment on one line

index.js Outdated

alfy.output = items => {
if (!Array.isArray(items)) {
throw new TypeError(`Expected \`items\` to be an Array, got ${typeof items}`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap ${typeof items} between backticks as well.

readme.md Outdated
{
title: 'Unicorn',
arg: '🦄',
variables: {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env

readme.md Outdated
{
title: 'Rainbow',
arg: '🌈',
variables: {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env

@luca-aurelia
Copy link
Author

Hey @SamVerschueren, latest commits should implement those changes. Let me know if there's anything else!

@@ -9,6 +9,7 @@ const cleanStack = require('clean-stack');
const dotProp = require('dot-prop');
const CacheConf = require('cache-conf');
const updateNotification = require('./lib/update-notification');
const {format} = require('./lib/utils');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Destructuring is not yet supported in Node 4. So just import it as utils and use utils.format below.

@@ -159,6 +165,8 @@ alfy.icon = {
delete: getIcon('ToolbarDeleteIcon')
};

alfy.env = process.env;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sindresorhus Should we put this in alfy.meta.env instead?

@@ -0,0 +1,36 @@
const isPlainObj = require('is-plain-obj');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'use strict';

const alfredworkflow = {arg: item.arg, variables: item.env};
const arg = JSON.stringify({alfredworkflow});
const newItem = Object.assign({}, item, {arg});
delete newItem.env;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we overwrite env with undefined instead in the line above? If I'm not mistaken delete is bad for performance.

};

// See https://www.alfredforum.com/topic/9070-how-to-workflowenvironment-variables/ for documentation on setting environment variables from Alfred workflows.
module.exports.format = item => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exports.format

"cache-conf": "^0.3.0",
"clean-stack": "^1.0.0",
"clean-stack": "^1.3.0",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert

"conf": "^0.11.0",
"dot-prop": "^4.0.0",
"dot-prop": "^4.1.1",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert

@@ -165,7 +166,12 @@ Return output to Alfred.

Type: `Array`

List of `Object` with any of the [supported properties](https://www.alfredapp.com/help/workflows/inputs/script-filter/json/).
List of `Object` with any of the [supported properties](https://www.alfredapp.com/help/workflows/inputs/script-filter/json/). If a list item has a `variables` property, it will be used to set Alfred's [Workflow Environment Variables](https://www.alfredapp.com/help/workflows/advanced/variables/) when the user selects the item.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a list item has a `env` property...

const itemWithMod = {
title: 'unicorn',
arg: '🦄',
env: {fabulous: true},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put it on a new line

};

const m = alfy();
const hook = cb => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return a promise instead of a callback, this way you can just use async/await in your tests.

@sindresorhus
Copy link
Owner

Closing for lack of activity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants