1 /// Authors: Thibaut CHARLES (Crom) cromfr@gmail.com
2 /// License: GPL-3.0
3 /// Copyright: Copyright Thibaut CHARLES 2016
4 
5 module tools.nwnbdb;
6 
7 import std.stdio;
8 import std.conv: to, ConvException;
9 import std.traits;
10 import std.string;
11 import std.path;
12 import std.file;
13 import std.exception;
14 import std.algorithm;
15 import std.math: log10;
16 import std.typecons: Tuple, Nullable;
17 import std.json;
18 import std.base64: Base64;
19 import std.regex;
20 version(unittest) import std.exception: assertThrown, assertNotThrown;
21 
22 import tools.common.getopt;
23 import nwn.biowaredb;
24 
25 
26 class ArgException : Exception{
27 	@safe pure nothrow this(string msg, string f=__FILE__, size_t l=__LINE__, Throwable t=null){
28 		super(msg, f, l, t);
29 	}
30 }
31 
32 int main(string[] args){
33 	if(args.length >= 2 && (args[1] == "-h" || args[1] == "--help")){
34 		writeln("Bioware database (foxpro .dbf) tool");
35 		writefln("Usage: %s (search)", args[0].baseName);
36 		return args.length <= 1;
37 	}
38 	if(args.any!(a => a == "--version")){
39 		import nwn.ver: NWN_LIB_D_VERSION;
40 		writeln(NWN_LIB_D_VERSION);
41 		return 0;
42 	}
43 	enforce(args.length >= 2, "No subcommand provided");
44 
45 	immutable command = args[1];
46 	args = args[0] ~ args[2..$];
47 
48 	switch(command){
49 		default:
50 			writefln("Unknown sub-command '%s'. See --help", command);
51 			return 1;
52 		case "search":{
53 			string playerid;
54 			bool moduleOnly = false;
55 			string name = r".*";
56 			enum DeletedFlag: int { no = 0, yes = 1, any }
57 			DeletedFlag deleted = DeletedFlag.any;
58 			string varTypeStr;
59 			bool noHeader;
60 			enum OutputFormat { text, json }
61 			OutputFormat outputFormat;
62 
63 
64 			auto res = getopt(args,
65 				"module", "Search only for module variables (vars not linked to a player character)", &moduleOnly,
66 				"playerid", "Player ID regex. Player ID is build by appending AccountName + CharacterName, truncated to 32 characters.", &playerid,
67 				"type", "variable type. Valid values are: Int, Float, String, Vector, Location, Object.", &varTypeStr,
68 				"name", "Variable name regex", &name,
69 				"deleted", "Search for either deleted variables or not. Valid values are: any (default), yes, no", &deleted,
70 				"no-header", "Do not show column header", &noHeader,
71 				"k|output-format", "Output format. Defaults to text. Valid values are: text, json", &outputFormat,
72 			);
73 			if(res.helpWanted || args.length != 2){
74 				improvedGetoptPrinter(
75 					"Search specific variables inside a database file\n"
76 					~"Example: " ~ args[0].baseName ~ " search foxpro-file-basename --name='^VAR_NAME_.*'",
77 					res.options);
78 				return 1;
79 			}
80 
81 			if(moduleOnly)
82 				enforce(playerid is null, "--module cannot be used with --playerid");
83 
84 			if(playerid is null)
85 				playerid = r".*";
86 
87 			Nullable!(BiowareDB.VarType) varType;
88 			if(varTypeStr !is null)
89 				varType = varTypeStr.to!(BiowareDB.VarType);
90 
91 
92 			auto dbName = args[1];
93 			if(dbName.extension.toLower == ".dbf")
94 				dbName = dbName.stripExtension;
95 
96 			const db = new BiowareDB(dbName, false);
97 			auto dbLen = db.length;
98 			int indexLength = cast(int)log10(dbLen) + 1;
99 			if(indexLength < 3)
100 				indexLength = 3;
101 
102 			auto rgxPlayerID = regex(playerid);
103 			auto rgxName = regex(name);
104 
105 			if(!noHeader && outputFormat == OutputFormat.text){
106 				writefln("%s %s %-20s %-32s %-8s %32s   %s",
107 					"D",
108 					"Idx".leftJustify(indexLength),
109 					"Timestamp",
110 					"Character ID",
111 					"Type",
112 					"Variable name",
113 					"Variable Value",
114 				);
115 				writeln("------------------------------------------------------------------------------------------------------------------------");
116 			}
117 			auto jsonValue = JSONValue(cast(JSONValue[])null);
118 
119 			foreach(var ; db){
120 				if(deleted != DeletedFlag.any && var.deleted != deleted)
121 					continue;
122 				if(!varType.isNull && varType.get != var.type)
123 					continue;
124 
125 				if(moduleOnly){
126 					if(var.playerid.toString != "")
127 						continue;
128 				}
129 				else{
130 					auto playerIDCap = var.playerid.toString.matchFirst(rgxPlayerID);
131 					if(playerIDCap.empty)
132 						continue;
133 				}
134 
135 				auto nameCap = var.name.matchFirst(rgxName);
136 				if(nameCap.empty)
137 					continue;
138 
139 
140 				if(outputFormat == OutputFormat.text){
141 					string value;
142 					final switch(var.type) with(BiowareDB.VarType){
143 						case Int:      value = db.getVariableValue!NWInt(var.index).to!string;        break;
144 						case Float:    value = db.getVariableValue!NWFloat(var.index).to!string;      break;
145 						case String:   value = db.getVariableValue!NWString(var.index).to!string;     break;
146 						case Vector:   value = db.getVariableValue!NWVector(var.index).to!string;     break;
147 						case Location: value = db.getVariableValue!NWLocation(var.index).to!string;   break;
148 						case Object:   value = Base64.encode(db.getVariableValue!(nwn.biowaredb.BinaryObject)(var.index)); break;
149 					}
150 
151 					writefln("%s %s %20s %-32s %-8s %32s = %s",
152 						var.deleted ? "D" : " ",
153 						var.index.to!string.leftJustify(indexLength),
154 						var.timestamp.toSimpleString,
155 						var.playerid.to!string,
156 						var.type,
157 						var.name,
158 						value,
159 					);
160 				}
161 				else if(outputFormat == OutputFormat.json){
162 					JSONValue varValue;
163 					varValue["deleted"] = var.deleted;
164 					varValue["index"] = var.index;
165 					varValue["timestamp"] = var.timestamp.toISOString;
166 					varValue["pcid"] = var.playerid.to!string;
167 					varValue["type"] = var.type;
168 					varValue["name"] = var.name;
169 					final switch(var.type) with(BiowareDB.VarType){
170 						case Int:      varValue["value"] = db.getVariableValue!NWInt(var.index);        break;
171 						case Float:    varValue["value"] = db.getVariableValue!NWFloat(var.index);      break;
172 						case String:   varValue["value"] = db.getVariableValue!NWString(var.index);     break;
173 						case Vector:   varValue["value"] = db.getVariableValue!NWVector(var.index).value;     break;
174 						case Location: varValue["value"] = db.getVariableValue!NWLocation(var.index).to!string;   break;
175 						case Object:   varValue["value"] = Base64.encode(db.getVariableValue!(nwn.biowaredb.BinaryObject)(var.index)); break;
176 					}
177 					jsonValue.array ~= varValue;
178 				}
179 			}
180 
181 			if(outputFormat == OutputFormat.json){
182 				writeln(jsonValue.toPrettyString);
183 			}
184 		}
185 		break;
186 	}
187 	return 0;
188 }
189 
190 
191 unittest {
192 	auto stdout_ = stdout;
193 	auto tmpOut = buildPath(tempDir, "unittest-nwn-lib-d-"~__MODULE__);
194 	stdout = File(tmpOut, "w");
195 	scope(success) tmpOut.remove();
196 	scope(exit) stdout = stdout_;
197 
198 
199 	assertThrown(main(["nwn-bdb"]) != 0);
200 	assert(main(["nwn-bdb","--help"]) == 0);
201 	assert(main(["nwn-bdb","--version"]) == 0);
202 
203 	// List everything
204 	stdout.reopen(null, "w");
205 	assert(main(["nwn-bdb", "search", "../../unittest/testcampaign", "--no-header"]) == 0);
206 	stdout.flush();
207 	assert(tmpOut.readText.splitLines.length == 8);
208 
209 	// List module vars
210 	stdout.reopen(null, "w");
211 	assert(main(["nwn-bdb", "search", "../../unittest/testcampaign", "--no-header", "--module"]) == 0);
212 	stdout.flush();
213 	assert(tmpOut.readText.splitLines.length == 6);
214 
215 	// Search player vars
216 	stdout.reopen(null, "w");
217 	assert(main(["nwn-bdb", "search", "../../unittest/testcampaign", "--no-header", "--playerid", "Crom 2"]) == 0);
218 	stdout.flush();
219 	assert(tmpOut.readText.splitLines.length == 2);
220 
221 	// Search var names
222 	stdout.reopen(null, "w");
223 	assert(main(["nwn-bdb", "search", "../../unittest/testcampaign", "--no-header", "--name", "ThisIs"]) == 0);
224 	stdout.flush();
225 	assert(tmpOut.readText.splitLines.length == 5);
226 
227 	// Output json
228 	stdout.reopen(null, "w");
229 	assert(main(["nwn-bdb", "search", "../../unittest/testcampaign", "--output-format", "json"]) == 0);
230 	stdout.flush();
231 	assert(tmpOut.readText.parseJSON.array.length == 8);
232 }