1

I'm writing an Angular app in node-webkit so that I can access the file system and other API's like desktop notifications and whatnot.

I have an Angular service (let's call it FileService) that uses fs.readdir() to read a directory of files and pushes the name of each file onto an array called allFiles as an object. So when this method finishes doing its thing, it returns an array allFiles of objects with this structure: {name: "some_file_name.txt"} via a callback.

Here's how the relevant bits of the FileService code looks like:

// ...
methods.getFileList = function (cb) {
    var allFiles = [];

    fs.readdir(directory, readFiles);

    // @todo: this is ugly as hell. Refactor later.
    function readFiles(err, files) {
        if (err) throw err;
        files.forEach(function (filename) {
            if (!isSupportedFileFormat(filename)) return;

            fs.open(directory + filename, 'r', function (err, fileDescriptor) {
                var buffer = new Buffer(85);

                fs.read(fileDescriptor, buffer, 0, buffer.length, null, function (err, bytesRead, buffer) {
                    allFiles.push({
                        name: filename,
                        contents: buffer.toString('utf8', 0, bytesRead)
                    });
                    fs.close(fileDescriptor);
                });
            });
        });
        cb(allFiles);
    }
};
// ...

In my main controller, let's call it MyCtrl, I have some code similar to this:

// FileService is the Angular factory I wrote that uses Node's fs to get files
FileService.getFileList(function (list) {
    $scope.listOfFiles = list;
    console.log($scope.listOfFiles);
});

And in my view (let's say it's in index.html), I have some code like this:

<ul class="file-list">
    <li ng-repeat="file in listOfFiles">{{ file.name }}</li>
</ul>

Now, I have everything set up so that the debugging console pops up when the app loads so I can see what's going on. When I build and run the app, I can see in the console a list of all the files showing up nicely in the scope variable listOfFiles. However, in my index.html view, I just get a blank area on the screen where the list should be. But once I move the app window or click on a textarea or even on the empty space where the list should be, the list of files magically pops up like I'd expect it to.

I started grasping at straws, and thought that maybe I needed to trigger a redraw to display my file list. So I tried the suggestions on this site and also some suggestions by Paul Irish, but none of those solutions fixed the problem.

Update: I meant to note that I tried doing

$scope.$apply(function() {
    $scope.listOfFiles = list;
}

but sadly that didn't fix the problem either.

Titus
  • 4,487
  • 6
  • 32
  • 42
  • Looks like `FileService.getFileList(function (list) {` is a non angular task.. So you need to manually invoke digest cycle by doing `scope.$apply()` so that DOm is updated to reflect the model bindings. In order to avoid all these i would just create an angular service which wraps this non angular functionality. – PSL Dec 19 '14 at 22:09
  • What is `FileService` ? is that a service you have created? Can you show it? – PSL Dec 19 '14 at 22:13
  • Hey @PSL, sorry for the vaugities. `FileService` is an Angular factory I wrote that uses Node's `fs` to get and return a list of files. Also, I should have mentioned that I tried `$scope.$apply(function(){$scope.files = files});` but sadly that didn't work. – Titus Dec 19 '14 at 22:15
  • nono.. Can you show your service. Which is the most critical part in your question after all right. Also check your console if you have any errors as well. Do not take a callback in your service, resolvedata with $q promise instead. It will make sure digest cycle is automatically invoked. But it is difficult to tell anything without you showing us your service. – PSL Dec 19 '14 at 22:15
  • Cool, @PSL I've added the snippet of code to the question. :) – Titus Dec 19 '14 at 22:19
  • `fs.open` is asyncronous right? similarly fs.read, so basically in the callback when it returns you are returning empty array. Instead of `console.log($scope.listOfFiles)` do `$scope.listOfFiles.length`. That is why even if you do $scope.$apply() buy the time digest cycle runs the callbacks would not have updated the file list array. – PSL Dec 19 '14 at 22:23
  • yep @PSL! They're always async unless it has "Sync" as a suffix (e.g., `fs.openSync()` is the synchronous version). – Titus Dec 19 '14 at 22:24
  • @PSL Oh my goodness, I think I see it. Basically `cb(allFiles)` gets called instantly, and hence the array is empty? – Titus Dec 19 '14 at 22:27
  • 1
    Yes right. Also since it is an array that you are returning and it is a reference type you still the update later. – PSL Dec 19 '14 at 22:29
  • @PSL, omg thanks for the fresh set of eyes! :) if you want to put it in an answer, I can mark this question as answered. – Titus Dec 19 '14 at 22:30
  • 1
    Sure i will put an answer with updated service code... – PSL Dec 19 '14 at 22:32

2 Answers2

2

Just as an expansion to the comments, you have too many non angular asynchronous activities going on there and also you are invoking the callback with the array reference (at that point will be empty) with no items in the array yet. That is why you don't see the data immediately. Also avoid taking a callback in angular services, instead use angular $q and return a promise. In case of non angular activity you could generally return a custom promise using deferred object ($q.defer). Creating a $q promise and chaining it though will make sure digest cycle will automatically be invoked after the promise chain callbacks are executed. This avoids the need to do a scope.$apply. In your case no digest cycle happens after the update so even though your model gets updated corresponding DOM binding(in the view) does not update(Which happens as a part of the digest cycle). When you click on some other control which probably may have its own ng-click or something digest cycle happens and you suddenly see the data.

In your service inject $q and do something like this (Yup a lot of re-factoring needs to be done, this is an untested quick and dirty code):

methods.getFileList = function () {

    var defer = $q.defer(); //Create a deferred object

    var allFiles = [];

    fs.readdir(directory, readFile);

    // @todo: this is ugly as hell. Refactor later.
    function readFile(err, files) {
        if (err) throw err;

        var cnt = 0; //Set up a counter

       //Resolve/reject promise here too if there is no file list

        files.forEach(function (filename) {
            if (!isSupportedFileFormat(filename)) { 

               //Resolve promise here too after the check
                return ++cnt;
            }

            fs.open(directory + filename, 'r', function (err, fileDescriptor) {
                var buffer = new Buffer(85);

                fs.read(fileDescriptor, buffer, 0, buffer.length, null, function (err, bytesRead, buffer) {
                    allFiles.push({
                        name: filename,
                        contents: buffer.toString('utf8', 0, bytesRead)
                    });
                    fs.close(fileDescriptor);

                    //Check for the counter (Reverify this logic)
                    if(++cntr === files.length){
                       defer.resolve(allFiles);
                    }

                });
            });
        });

    }

  //Return a promise
  return defer.promise;
};

And in your controller chain through the promise returned by the service method:-

FileService.getFileList().then(function (list) {
    $scope.listOfFiles = list;
    console.log($scope.listOfFiles);
});
PSL
  • 123,204
  • 21
  • 253
  • 243
  • 2
    Nitpick, the promise will never be resolved if the directory has no files. – Hargo Dec 19 '14 at 22:47
  • @Hargobind Yeah true.. i actually got headache looking at the callback hell and forgot all those scenarios.. :) The check needs to be there in other places as well. I think OP should be able to figure it out now.. Just have the concept in the answer. ;) – PSL Dec 19 '14 at 22:48
  • 1
    Thanks! I just tested this and it works great. :) Also, I've used `$q` before but thought I could get away with not using it here. I forgot that `$q` has the added benefit of triggering digest... I'll remember that for next time :) And I'll try to clean up that callback hell. heh. Thanks again @PSL! :) – Titus Dec 19 '14 at 22:56
  • 1
    @titus you are welcome. Just to mention code in the answer is not totally complete you could also associate deference promise with each of the files and just resolve them instead of the counter. That Wil be a better approach. Loop thru array of objects with each object with filename and a promise and return q.all of promises – PSL Dec 19 '14 at 23:00
  • @PSL cool! Hey, by "associate deference promise" did you mean adding a promise to each file, a la `$q.all()`? – Titus Dec 21 '14 at 07:58
1

As a follow up to @PSL's answer, I refactored my code to avoid callback hell, and used it as an opportunity to learn more about $q. I still have my reservations, but I thought I'd post it on here in case it helps anybody. This is what I came up with:

methods.getNotes = function () {
    var allFiles = [];

    return getFiles(directory).then(processFiles);

    function getFiles(directory) {
        return $q(function (resolve, reject) {
            fs.readdir(directory, function (err, files) {
                if (err) {
                    resolve(err);
                } else {
                    resolve(files);
                }
            });
        });
    }

    function processFiles(files) {
        return $q(function (resolve, reject) {
            var promises = [];
            files.forEach(function (filename) {
                promises.push(processFile(filename));
            });

            $q.all(promises).then(function () {
                resolve(allFiles);
            });
        });
    }

    function processFile(filename) {
        return $q(function (resolve, reject) {
            if (!isSupportedFileFormat(filename)) {
                resolve();
                return 'Error processing ' + filename;
            }

            openFile(directory, filename, 'r').then(function () {
                resolve();
            });
        });
    }

    function openFile(directory, filename, flags) {
        return $q(function (resolve, reject) {
            fs.open(directory + filename, flags, function (err, fileDescriptor) {
                if (err) {
                    resolve(err);
                    // implement later
                } else {
                    readFile(fileDescriptor, filename).then(function (err, bytesRead, buffer) {
                        if (err) return err;
                        resolve(bytesRead, buffer);
                    });
                }
            });
        });
    }

    function readFile(fileDescriptor, filename) {
        return $q(function (resolve, reject) {
            var buffer = new Buffer(85);
            fs.read(fileDescriptor, buffer, 0, buffer.length, null, function (err, bytesRead, buffer) {
                allFiles.push({
                    filename: filename,
                    title: stripFileExtension(filename),
                    contents: buffer.toString('utf8', 0, bytesRead)
                });
                fs.close(fileDescriptor);
                resolve(err, bytesRead, buffer);
            });
        });
    }
};

I think this is a bit verbose, but it is flatter, avoids using a counter for the files, and is easier to read and understand... at least to me, anyway. You'll notice that I've skipped implementing error handling for now, as I found Angular's $q.all() will simply bail if any of the promises in the array are rejected. Apparently the original Q implementation does have an allSettled method for these kind of things, but unfortunately it doesn't look like Angular has ported that yet (see this question).

Any suggestions and input are welcome :)

Community
  • 1
  • 1
Titus
  • 4,487
  • 6
  • 32
  • 42