2016-02-13

Very short – if not the shortest – Swift logging library.

I'm just a lone dude making risky-dink iOS apps. I do not want or need a fancy logging library. Here is what I do not need:

  • Colors.
  • Multiple channels.
  • Logging anywhere else other than standard output.
  • File rotation.
  • Complicated filters.
  • Helper functions that save me from typing 10 extra characters.
  • Verbosity.
  • Levels.
  • Formatters.
  • Thread safety.
  • Appenders.
  • XML anything.
  • Document Object Model hierarchies.
  • Targets.
  • Layouts.

I don't even need date and time information. That line prefix that NSLog kicks out? Too long! You know what I like column 1 of my log files to contain? The first character of my log message.

So just use print(), right? Well, the problem is, I throw all those print statements around, but I'm only debugging 1 or 2 entities at a time, and I don't want to look at all those print statements from 2 weeks ago confusing me while I debug something now.

So the only feature I need is categories. I specify a category when I write the call to my logging function along with a message. Somewhere central, I can turn on or off what categories get logged.

So my requirements are:
  • Print the message I pass in.
  • Unless I don't want that message printed right now.

So anyway, here is my fancy logging library.
struct L {
    // These are the only categories that will be logged. Adjust accordingly.
    private static let categories = ["Pilot"]
    
    typealias M = ((String) -> Void)
    static let c:[String : M] = {
        var tmp = [String : M]()
        for s in L.categories {
            tmp[s] = L.m
        }
        return tmp
    }()
    
    static func m(message:String) {
        print(message)
    }
}

And here is how you use it.

class Pilot {
    private let n = String(Pilot)
....
    L.c[n]?("radians = \(radians)")
....
    L.c["Err"]?("state was never set!")
}

class Location {
    private let c = String(Location)
....
    L.c[c]?("directionToLocation = \(directionToLocation)")
}

Because categories only has "Pilot", only the "radians" log line will get printed. I can chose to use the names of classes for the category or any string I want.

When I'm ready to push to the App Store, I can leave categories as "Err" to record any errors. As if I'll ever be able to talk a user through pulling and sending me their device console logs.

2015-11-27

Trouble installing Perl module GitLab::API::v3 on OS X El Capitan (10.11.1)

I hit this problem while writing my own solution to backing up project issues on GitLab.com.

My script uses OS X El Capitan (10.11.1) system Perl.

I installed GitLab::API::v3 Perl module, which is found at https://metacpan.org/pod/GitLab::API::v3

This GitLab API Perl client is talked about here: https://about.gitlab.com/applications/#api-clients

I used cpanm to pull down CPAN modules, and it is described here: http://www.cpan.org/modules/INSTALL.html

Preamble over, on to the problem.

GitLab::API::v3 uses Moose, but the version of Moose that comes with Perl 5.18.2 (OS X El Capitan system Perl) will not work with the version of Runtime which cpanm automatically pulls down to use in GitLab::API::v3. I noticed this in unit test failure from Type::Tiny 30-integration/Moo/inflation2.t. So I pulled down a new Moose first.

Here was my order of commands, as pulled from a combination of shell command history and my brain's recollection.
  1. cpanm GitLab::API::v3 (fails)
  2. cpanm TAP::Harness::Env
  3. cpanm Const::Fast (fails)
  4. cpanm Type::Tiny (fails)
  5. cpanm Moose
  6. cpanm GitLab::API::v3
Here is what the error looked like in build.log:

Invalid version format (version required) at /Users/jeff/perl5/lib/perl5/Module/Runtime.pm line 386.
BEGIN failed--compilation aborted at t/30-integration/Moo/inflation2.t line 49.
t/30-integration/Moo/inflation2.t ......................... 
Dubious, test returned 255 (wstat 65280, 0xff00)
No subtests run

[42 LINES LATER]

Test Summary Report
-------------------
t/30-integration/Moo/inflation2.t                       (Wstat: 65280 Tests: 0 Failed: 0)
  Non-zero exit status: 255
  Parse errors: No plan found in TAP output
Files=151, Tests=1666, 19 wallclock secs ( 0.68 usr  0.32 sys + 16.10 cusr  2.28 csys = 19.38 CPU)
Result: FAIL
Failed 1/151 test programs. 0/1666 subtests failed.
make: *** [test_dynamic] Error 255
-> FAIL Installing Type::Tiny failed. See /Users/jeff/.cpanm/work/1448136089.38532/build.log for details. Retry with --force to force install it.



2015-11-26

Perl script to backup GitLab.com project issues.

In using the GitLab.com hosting service, I could not find an archive button to download the issues for a project. If such functionality exists, it is well hidden from me.

It does have a button to download a zip of the git repos itself, but I find this incredibly bizarre, since if I'm using GitLab as a git remote, that means I already have the repos.

What I wrote is only meant to be a primitive last resort backup. To manually reenter all my issues from the saved output of this script would be a giant pain in the ass, but at least recovery would be possible. Before, if all my issues magically disappeared from GitLab.com, accurate restoration would be impossible.

You'll have to install GitLab::API::v3 module. Right now, it doesn't handle issue comments, I had to add the issue_comments method myself. See bottom of this post that.

In any case, here is:

use v5.18;
use warnings;
use strict;

use Data::Dumper;
use GitLab::API::v3;
use POSIX qw(strftime);

my $v3_api_url = 'http://gitlab.com/api/v3';
my $token = 'YeAhR1GhTkEePgUeSs1nG';

my $api = GitLab::API::v3->new(
url   => $v3_api_url,
token => $token,
);

my $project_id_num = 123456;

say "Getting issues for project $project_id_num.";
my $all_issues = $api->paginator(
    'issues',
    $project_id_num,
)->all();

my $backup_root = '/Users/jeff/VGT/GitLab backup/';
my $now_string = strftime("%Y-%m-%d_%H-%M-%S", localtime);
my $backup_dir = $backup_root . $now_string;
mkdir $backup_dir or die "Fail to mkdir $backup_dir";
my $issues_out = $backup_dir . '/issues.dump';

say "Logging issues.";
open (my $issues_fh, '>', $issues_out) or die "Cannot open $issues_out";
print $issues_fh Dumper($all_issues);
close $issues_fh;

say "Getting comments.";
for my $issue_ref (@$all_issues) {
    my $issue_id = $issue_ref->{'id'};
    my $iid = $issue_ref->{'iid'};
    my $comments = $api->issue_comments($project_id_num, $issue_id);
    
    next if scalar(@$comments) == 0;
    say "Logging comments for $iid.";
    
    my $comment_dir = $backup_dir . '/' . $iid;
    mkdir $comment_dir or die "Fail to mkdir $comment_dir";
    
    my $comment_out = $comment_dir . '/comments.dump';
    open my $comment_fh, '>', $comment_out or die "Cannot open $comment_out";
    print $comment_fh Dumper($comments);
    close $comment_fh;
}

say "Logging labels.";
my $labels = $api->labels($project_id_num);
my $labels_out = $backup_dir . '/labels.dump';
open my $labels_fh, '>', $labels_out or die "Cannot open $labels_out";
print $labels_fh Dumper($labels);
close $labels_fh;

exit 0;


This is what I added to wherever-your-library-is/GitLab/API/v3.pm. Here is git diff output:

$ git diff 1c3c02b64f3a3c608891bc3021a002892b96958e v3.pm 
diff --git a/v3.pm b/v3.pm
index bd745c0..b8effea 100644
--- a/v3.pm
+++ b/v3.pm
@@ -1866,6 +1866,27 @@ sub edit_issue {
     return $self->put( $path, ( defined($params) ? $params : () ) );
 }
 
+=head2 issue_comments
+ 
+    my $comments = $api->issue_comments(
+        $project_id,
+        $issue_id,
+     );
+ 
+ Sends a Clt;GET> request to Clt;/projects/:project_id/issues/:issue_id/notes> and returns the decoded/deserialized response body.
+ 
+=cut
+
+sub issue_comments {
+    my $self = shift;
+    croak 'issue_comments must be called with 2 arguments' if @_ != 2;
+    croak 'The #1 argument ($project_id) to issue_comments must be a scalar' if ref($_[0]) or (!defined $_[0]);
+    croak 'The #2 argument ($$issue_id) to issue_comments must be a scalar' if ref($_[1]) or (!defined $_[1]);
+    my $path = sprintf('/projects/%s/issues/%s/notes', (map { uri_escape($_) } @_));
+    $log->infof( 'Making %s request against %s with params %s.', 'GET', $path, undef );
+    return $self->get( $path );
+}
+
 =head1 LABEL METHODS
 
 See L<http://doc.gitlab.com/ce/api/labels.html>.

And that's it!

2015-07-26

JSTalk script for Acorn, crunching images for iOS

So it was time to crunch images for inclusion in an iOS app again. Because I ditched GIMP and moved to Acorn, I can't reuse my old Scheme script.

I had to use JSTalk instead. It is by the same dude that wrote Acorn. It is basically a JavaScript wrapper to Core Foundation.

I decided on JSTalk over AppleScript after glancing at the AppleScript syntax for about 5 seconds. I heard about JSTalk from the Acorn scripting docs.

Anyway, I had finished editing my "@2" images manually, and I needed the half-size image files. I had them all in one directory.

I ran this script straight from the JSTalk Editor.

var acorn = JSTalk.application("Acorn");
var dirPath = @"/Users/jeff/VGT/art/vegetable photos/Working Temp/";
var fileManager = [[NSFileManager alloc] init];
var dirEnum = [fileManager enumeratorAtPath:dirPath];
var fileName = [dirEnum nextObject];

while (fileName) {
    NSLog(fileName);
    
    var range = [fileName rangeOfString:@"@2.jpg"];
    if (range.location == NSNotFound) {
        continue;
    }
    
    var fullPathFileName = dirPath + fileName;
    NSLog(fullPathFileName);
    
    var doc = acorn.open_(fullPathFileName);
    var size = doc.canvasSize()
    var newWidth = size.width / 2;
    doc.scaleImageToWidth(newWidth);
    
    var endIndex = [fileName length] - 6;
    // How do I stringify this number so I can pass it to NSLog?
    // NSLog(endIndex);
    
    var modFileName = [fileName substringToIndex:endIndex];
    NSLog(modFileName);
    var modFullPathFileName = dirPath + modFileName + ".jpg";
    NSLog(modFullPathFileName);
    
    doc.dataRepresentationOfType("public.png").writeToFile(modFullPathFileName);
    doc.close();
    fileName = [dirEnum nextObject];
}

Some problems I encountered:
  • I could only pass 1 parameter to NSLog. The string format specifiers (%s and %@) just got lost.
  • I could not do while ((fileName = [dirEnum nextObject])). It just couldn't understand that.
  • The class method [NSFileManaged defaultManager] did not work, so I had to init my own. I'm not sure if that is a JSTalk problem or because I've never written an OS X app before and that is normal.
Ug! A buddy just showed me the JavaScript for Automation video from WWCD 2014. I'm so far behind the tech curve, as usual.

2015-07-12

Perl one-liner: files in this directory also in that directory?

It's been too long since I've used Perl. But anyway...

Problem: I copied files from one folder to another folder manually. Are any files missing?

In the below example "." is the single source folder and "../vegetables.xcassets" is the parent folder of various destination folders.

perl -e 'opendir(D,".");@f=grep{/jpg$/}readdir(D);for $i (@f){@g = glob qq("../vegetables.xcassets/*/$i"); $m="Not $i"; if (-f $g[0]) {$m="OK"}; print "$m $g[0]\n";}'

Sample output:

OK ../vegetables.xcassets/oregano_large.imageset/oregano_large.jpg
Not oregano_large@2x.jpg 
OK ../vegetables.xcassets/oregano_small.imageset/oregano_small.jpg
OK ../vegetables.xcassets/oregano_small.imageset/oregano_small@2x.jpg

Here you can see I forgot to copy oregano_large@2x.jpg.

I used Perl because I forgot how awful shell handles spaces in file names, and I tried to do it that way first. I realize my example output does not contain spaces in the file names, but you don't want to see the listing all the way down to "summer squash".

If you are asking why did I not automate the copy of the files, it is because the copy happened with Xcode, and I needed its internal XML configuration mangling to happen at the same time.

2015-04-04

Viewing exception in lldb

It has been a while since I wrote one of these. Time to do it again.

My app aborted on an assertion.

I had the "All Exceptions" breakpoint enabled.


I got this line in Xcode console.
*** Assertion failure in -[UITableView _endCellAnimationsWithContext:], /SourceCache/UIKit_Sim/UIKit-3318.93/UITableView.m:1314
In the Debug navigator, I click on the objc_exception_throw frame.


In the Console of the Debug area, I type in:
expr -o -- $eax
And this tells me exactly what my problem is.


Note: the $eax only works in the Simulator. When debugging on an actual device use $r0.

2015-03-02

Object-oriented State pattern with view controllers in Objective-C and iOS

I have a UIViewController subclass that is controlling a view hierarchy which represents a portion of the UI. That portion of the UI can be in one of three modes: browse, edit, or empty. These modes can change back and forth at run time by user actions.

Right now I have code in every method of the view controller that looks like this:

NSAssert(self.mode == SBModeBrowse || self.mode == SBModeEmpty, @"Should not be called in SBMode %d", self.mode);

And this:

if (self.mode != SBModeEmpty)
{
    [self performScrollCompletion];
}

And so on.

Even worse, I'm probably missing a few asserts and if-thens, and the code just happens to work at the moment.

It seems the object-oriented pattern that solves this situation is the State pattern.

It took me a while to figure out how to do this with UIViewController subclasses because the state behavior will affect UIControls wired up via IBOutlets to the view controller itself. How are the behavior classes supposed to manipulate these objects?

Also, I did not read the State pattern explanation closely enough, so I started off trying to subclass my view controller subclasses instead of merely adding a property to them for a behavior object.

I made a simple sandbox project to teach myself how to add the State pattern to view controllers. Here is my specification.
  • Display a UILabel with a number.
  • Display a UIButton.
  • Display a UIStepper.
  • Start in Browse mode.
  • In Browse mode:
    • Only button allows user taps.
    • Only label and button showing.
    • Button title is "Edit" and tapping it goes to Edit mode.
    • Stepper is invisible and does not allow taps.
  • In Edit Mode:
    • Stepper is visible and allows taps.
    • Stepper adjusts number in label.
    • Button title is "Browse" and tapping it does to Browse mode.
  • When exiting Edit mode, if the number in the label is less than 1, then enter Empty mode.
  • In Empty Mode:
    • Stepper is invisible and no taps allowed.
    • Label background is red.
    • Button tap moves to Edit mode.
Here are the classes and code to accomplish this by using the State pattern and view controllers.

InfoKeys.h


#ifndef SandBox3_InfoKeys_h
#define SandBox3_InfoKeys_h
static NSString *const keyButton = @"button";
static NSString *const keyStepper = @"stepper";
static NSString *const keyLabel = @"label";
#endif

ViewControlller.h


@class Behavior;
@interface ViewController : UIViewController
@property (weak, nonatomic) IBOutlet UIButton *button;
@property (weak, nonatomic) IBOutlet UILabel *label;
@property (weak, nonatomic) IBOutlet UIStepper *stepper;
@property (strong, nonatomic) Behavior *behavior;
@property (assign, nonatomic) uint16_t labelValue;
- (IBAction)buttonTapped:(id)sender;
- (IBAction)stepperTapped:(id)sender;
@end

ViewController.m


#import "ViewController.h"
#import "Behavior.h"
#import "InfoKeys.h"

@implementation ViewController
- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    self.behavior = [[Behavior alloc] initWithInfo:@{keyButton: self.button, keyLabel: self.label, keyStepper: self.stepper}];
}

- (IBAction)buttonTapped:(id)sender
{
    [self.behavior buttonTapped:@{keyButton: sender, keyStepper: self.stepper, keyLabel: self.label}];
}

- (IBAction)stepperTapped:(id)sender
{
    [self.behavior stepperTapped:@{keyStepper: sender, keyLabel: self.label}];
}
@end

Behavior.h


@interface Behavior : NSObject
- (instancetype)initWithInfo:(NSDictionary *)info;
- (void)buttonTapped:(NSDictionary *)info;
- (void)stepperTapped:(NSDictionary *)info;
@end

Behavior.m


#import "Behavior.h"
#import "BehaviorBrowse.h"
#import "BehaviorEdit.h"
#import "BehaviorEmpty.h"
#import "InfoKeys.h"

@implementation Behavior {
    Behavior *_mode;
}

- (instancetype)initWithInfo:(NSDictionary *)info
{
    self = [super init];
    if (self) {
        // Prevent problems if subclasses call this method (for example, as super in overrides).
        if ([self isMemberOfClass:[Behavior class]]) {
            _mode = [[BehaviorBrowse alloc] initWithInfo:info];
        }
    }
    return self;
}

- (void)buttonTapped:(NSDictionary *)info
{
    [_mode buttonTapped:info];
    UIStepper *stepper = info[keyStepper];
    // Coming back from Empty should allow mode to go to Edit so user can increase 0 value.
    if (stepper.value < 1 && ![_mode isKindOfClass:[BehaviorEmpty class]]) {
        _mode = [[BehaviorEmpty alloc] initWithInfo:info];
    }
    else {
        if ([_mode isKindOfClass:[BehaviorEdit class]]) {
            _mode = [[BehaviorBrowse alloc] init];
        }
        else { // Coming from Empty ends up here also.
            _mode = [[BehaviorEdit alloc] init];
        }
    }
}

- (void)stepperTapped:(NSDictionary *)info
{
    [_mode stepperTapped:info];
}
@end

BehaviorBrowse.m


#import "BehaviorBrowse.h"
#import "InfoKeys.h"

@implementation BehaviorBrowse

- (instancetype)initWithInfo:(NSDictionary *)info
{
    self = [super init];
    if (self) {
        UIButton *button = info[keyButton];
        UILabel *label = info[keyLabel];
        UIStepper *stepper = info[keyStepper];
        label.text = [NSString stringWithFormat:@"%.0f", stepper.value];
        stepper.alpha = 0.0;
        stepper.userInteractionEnabled = NO;
        // Button title shows user way to go to opposite mode.
        [button setTitle:@"Edit" forState:UIControlStateNormal];
    }
    return self;
}

- (void)buttonTapped:(NSDictionary *)info
{
    UIButton *button = (UIButton *)info[keyButton];
    // Button titles inform user of way back to the mode they are about to leave.
    [button setTitle:@"Browse" forState:UIControlStateNormal];
    UIStepper *stepper = (UIStepper *)info[keyStepper];
    [UIView animateWithDuration:0.3
                     animations:^(void){stepper.alpha = 1.0;}
                     completion:^(BOOL finished) {stepper.userInteractionEnabled = YES;}];
}

- (void)stepperTapped:(NSDictionary *)info
{
    NSAssert(NO, @"Stepper should not be tapable during browse mode.");
}

@end

BehaviorEdit.m


#import "BehaviorEdit.h"
#import "InfoKeys.h"

@implementation BehaviorEdit

- (void)buttonTapped:(NSDictionary *)info
{
    UIStepper *stepper = info[keyStepper];
    UIButton *button = info[keyButton];
    stepper.userInteractionEnabled = NO;
    // Let user know that button can bring them back to Edit from whatever mode comes next.
    [button setTitle:@"Edit" forState:UIControlStateNormal];
    [UIView animateWithDuration:0.3 animations:^(void){stepper.alpha = 0.0;}];
}

- (void)stepperTapped:(NSDictionary *)info
{
    UILabel *label = info[keyLabel];
    UIStepper *stepper = info[keyStepper];
    label.text = [NSString stringWithFormat:@"%.0f", stepper.value];
}

@end

BehaviorEmpty.m


#import "BehaviorEmpty.h"
#import "InfoKeys.h"

@implementation BehaviorEmpty

- (instancetype)initWithInfo:(NSDictionary *)info
{
    self = [super init];
    if (self) {
        UILabel *label = info[keyLabel];
        label.backgroundColor = [UIColor redColor];
        UIStepper *stepper = info[keyStepper];
        stepper.alpha = 0.0;
        stepper.userInteractionEnabled = NO;
    }
    return self;
}

- (void)buttonTapped:(NSDictionary *)info
{
    UIStepper *stepper = info[keyStepper];
    UIButton *button = info[keyButton];
    [button setTitle:@"Browse" forState:UIControlStateNormal];
    UILabel *label = info[keyLabel];
    label.backgroundColor = [UIColor lightGrayColor];
    [UIView animateWithDuration:0.3
                     animations:^(void){stepper.alpha = 1.0;}
                     completion:^(BOOL finished) {stepper.userInteractionEnabled = YES;}];
}

- (void)stepperTapped:(NSDictionary *)info
{
    NSAssert(NO, @"Stepper should not be tapable during empty mode.");
}


I am always interested in hearing better ways to do this.