1: <?php
2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16:
17:
18: App::uses('AppShell', 'Console/Command');
19: App::uses('File', 'Utility');
20: App::uses('Folder', 'Utility');
21: App::uses('Hash', 'Utility');
22:
23: 24: 25: 26: 27:
28: class ExtractTask extends AppShell {
29:
30: 31: 32: 33: 34:
35: protected $_paths = array();
36:
37: 38: 39: 40: 41:
42: protected $_files = array();
43:
44: 45: 46: 47: 48:
49: protected $_merge = false;
50:
51: 52: 53: 54: 55:
56: protected $_file = null;
57:
58: 59: 60: 61: 62:
63: protected $_storage = array();
64:
65: 66: 67: 68: 69:
70: protected $_tokens = array();
71:
72: 73: 74: 75: 76:
77: protected $_translations = array();
78:
79: 80: 81: 82: 83:
84: protected $_output = null;
85:
86: 87: 88: 89: 90:
91: protected $_exclude = array();
92:
93: 94: 95: 96: 97:
98: protected $_extractValidation = true;
99:
100: 101: 102: 103: 104:
105: protected $_validationDomain = 'default';
106:
107: 108: 109: 110: 111:
112: protected $_extractCore = false;
113:
114: 115: 116: 117: 118:
119: protected function _getPaths() {
120: $defaultPath = APP;
121: while (true) {
122: $currentPaths = count($this->_paths) > 0 ? $this->_paths : array('None');
123: $message = __d(
124: 'cake_console',
125: "Current paths: %s\nWhat is the path you would like to extract?\n[Q]uit [D]one",
126: implode(', ', $currentPaths)
127: );
128: $response = $this->in($message, null, $defaultPath);
129: if (strtoupper($response) === 'Q') {
130: $this->err(__d('cake_console', 'Extract Aborted'));
131: return $this->_stop();
132: } elseif (strtoupper($response) === 'D' && count($this->_paths)) {
133: $this->out();
134: return;
135: } elseif (strtoupper($response) === 'D') {
136: $this->err(__d('cake_console', '<warning>No directories selected.</warning> Please choose a directory.'));
137: } elseif (is_dir($response)) {
138: $this->_paths[] = $response;
139: $defaultPath = 'D';
140: } else {
141: $this->err(__d('cake_console', 'The directory path you supplied was not found. Please try again.'));
142: }
143: $this->out();
144: }
145: }
146:
147: 148: 149: 150: 151:
152: public function execute() {
153: if (!empty($this->params['exclude'])) {
154: $this->_exclude = explode(',', $this->params['exclude']);
155: }
156: if (isset($this->params['files']) && !is_array($this->params['files'])) {
157: $this->_files = explode(',', $this->params['files']);
158: }
159: if (isset($this->params['paths'])) {
160: $this->_paths = explode(',', $this->params['paths']);
161: } elseif (isset($this->params['plugin'])) {
162: $plugin = Inflector::camelize($this->params['plugin']);
163: if (!CakePlugin::loaded($plugin)) {
164: CakePlugin::load($plugin);
165: }
166: $this->_paths = array(CakePlugin::path($plugin));
167: $this->params['plugin'] = $plugin;
168: } else {
169: $this->_getPaths();
170: }
171:
172: if (isset($this->params['extract-core'])) {
173: $this->_extractCore = !(strtolower($this->params['extract-core']) === 'no');
174: } else {
175: $response = $this->in(__d('cake_console', 'Would you like to extract the messages from the CakePHP core?'), array('y', 'n'), 'n');
176: $this->_extractCore = strtolower($response) === 'y';
177: }
178:
179: if (!empty($this->params['exclude-plugins']) && $this->_isExtractingApp()) {
180: $this->_exclude = array_merge($this->_exclude, App::path('plugins'));
181: }
182:
183: if (!empty($this->params['ignore-model-validation']) || (!$this->_isExtractingApp() && empty($plugin))) {
184: $this->_extractValidation = false;
185: }
186: if (!empty($this->params['validation-domain'])) {
187: $this->_validationDomain = $this->params['validation-domain'];
188: }
189:
190: if ($this->_extractCore) {
191: $this->_paths[] = CAKE;
192: $this->_exclude = array_merge($this->_exclude, array(
193: CAKE . 'Test',
194: CAKE . 'Console' . DS . 'Templates'
195: ));
196: }
197:
198: if (isset($this->params['output'])) {
199: $this->_output = $this->params['output'];
200: } elseif (isset($this->params['plugin'])) {
201: $this->_output = $this->_paths[0] . DS . 'Locale';
202: } else {
203: $message = __d('cake_console', "What is the path you would like to output?\n[Q]uit", $this->_paths[0] . DS . 'Locale');
204: while (true) {
205: $response = $this->in($message, null, rtrim($this->_paths[0], DS) . DS . 'Locale');
206: if (strtoupper($response) === 'Q') {
207: $this->err(__d('cake_console', 'Extract Aborted'));
208: return $this->_stop();
209: } elseif ($this->_isPathUsable($response)) {
210: $this->_output = $response . DS;
211: break;
212: } else {
213: $this->err(__d('cake_console', 'The directory path you supplied was not found. Please try again.'));
214: }
215: $this->out();
216: }
217: }
218:
219: if (isset($this->params['merge'])) {
220: $this->_merge = !(strtolower($this->params['merge']) === 'no');
221: } else {
222: $this->out();
223: $response = $this->in(__d('cake_console', 'Would you like to merge all domain and category strings into the default.pot file?'), array('y', 'n'), 'n');
224: $this->_merge = strtolower($response) === 'y';
225: }
226:
227: if (empty($this->_files)) {
228: $this->_searchFiles();
229: }
230:
231: $this->_output = rtrim($this->_output, DS) . DS;
232: if (!$this->_isPathUsable($this->_output)) {
233: $this->err(__d('cake_console', 'The output directory %s was not found or writable.', $this->_output));
234: return $this->_stop();
235: }
236:
237: $this->_extract();
238: }
239:
240: 241: 242: 243: 244: 245: 246: 247: 248: 249: 250:
251: protected function _addTranslation($category, $domain, $msgid, $details = array()) {
252: $context = '';
253: if (isset($details['msgctxt'])) {
254: $context = $details['msgctxt'];
255: }
256:
257: if (empty($this->_translations[$category][$domain][$msgid][$context])) {
258: $this->_translations[$category][$domain][$msgid][$context] = array(
259: 'msgid_plural' => false,
260: );
261: }
262:
263: if (isset($details['msgid_plural'])) {
264: $this->_translations[$category][$domain][$msgid][$context]['msgid_plural'] = $details['msgid_plural'];
265: }
266: if (isset($details['file'])) {
267: $line = 0;
268: if (isset($details['line'])) {
269: $line = $details['line'];
270: }
271: $this->_translations[$category][$domain][$msgid][$context]['references'][$details['file']][] = $line;
272: }
273: }
274:
275: 276: 277: 278: 279:
280: protected function _extract() {
281: $this->out();
282: $this->out();
283: $this->out(__d('cake_console', 'Extracting...'));
284: $this->hr();
285: $this->out(__d('cake_console', 'Paths:'));
286: foreach ($this->_paths as $path) {
287: $this->out(' ' . $path);
288: }
289: $this->out(__d('cake_console', 'Output Directory: ') . $this->_output);
290: $this->hr();
291: $this->_extractTokens();
292: $this->_extractValidationMessages();
293: $this->_buildFiles();
294: $this->_writeFiles();
295: $this->_paths = $this->_files = $this->_storage = array();
296: $this->_translations = $this->_tokens = array();
297: $this->_extractValidation = true;
298: $this->out();
299: $this->out(__d('cake_console', 'Done.'));
300: }
301:
302: 303: 304: 305: 306:
307: public function getOptionParser() {
308: $parser = parent::getOptionParser();
309:
310: $parser->description(
311: __d('cake_console', 'CakePHP Language String Extraction:')
312: )->addOption('app', array(
313: 'help' => __d('cake_console', 'Directory where your application is located.')
314: ))->addOption('paths', array(
315: 'help' => __d('cake_console', 'Comma separated list of paths.')
316: ))->addOption('merge', array(
317: 'help' => __d('cake_console', 'Merge all domain and category strings into the default.po file.'),
318: 'choices' => array('yes', 'no')
319: ))->addOption('output', array(
320: 'help' => __d('cake_console', 'Full path to output directory.')
321: ))->addOption('files', array(
322: 'help' => __d('cake_console', 'Comma separated list of files.')
323: ))->addOption('exclude-plugins', array(
324: 'boolean' => true,
325: 'default' => true,
326: 'help' => __d('cake_console', 'Ignores all files in plugins if this command is run inside from the same app directory.')
327: ))->addOption('plugin', array(
328: 'help' => __d('cake_console', 'Extracts tokens only from the plugin specified and puts the result in the plugin\'s Locale directory.')
329: ))->addOption('ignore-model-validation', array(
330: 'boolean' => true,
331: 'default' => false,
332: 'help' => __d('cake_console', 'Ignores validation messages in the $validate property.' .
333: ' If this flag is not set and the command is run from the same app directory,' .
334: ' all messages in model validation rules will be extracted as tokens.'
335: )
336: ))->addOption('validation-domain', array(
337: 'help' => __d('cake_console', 'If set to a value, the localization domain to be used for model validation messages.')
338: ))->addOption('exclude', array(
339: 'help' => __d('cake_console', 'Comma separated list of directories to exclude.' .
340: ' Any path containing a path segment with the provided values will be skipped. E.g. test,vendors'
341: )
342: ))->addOption('overwrite', array(
343: 'boolean' => true,
344: 'default' => false,
345: 'help' => __d('cake_console', 'Always overwrite existing .pot files.')
346: ))->addOption('extract-core', array(
347: 'help' => __d('cake_console', 'Extract messages from the CakePHP core libs.'),
348: 'choices' => array('yes', 'no')
349: ));
350:
351: return $parser;
352: }
353:
354: 355: 356: 357: 358:
359: protected function _extractTokens() {
360: foreach ($this->_files as $file) {
361: $this->_file = $file;
362: $this->out(__d('cake_console', 'Processing %s...', $file), 1, Shell::VERBOSE);
363:
364: $code = file_get_contents($file);
365: $allTokens = token_get_all($code);
366:
367: $this->_tokens = array();
368: foreach ($allTokens as $token) {
369: if (!is_array($token) || ($token[0] !== T_WHITESPACE && $token[0] !== T_INLINE_HTML)) {
370: $this->_tokens[] = $token;
371: }
372: }
373: unset($allTokens);
374: $this->_parse('__', array('singular'));
375: $this->_parse('__n', array('singular', 'plural'));
376: $this->_parse('__d', array('domain', 'singular'));
377: $this->_parse('__c', array('singular', 'category'));
378: $this->_parse('__dc', array('domain', 'singular', 'category'));
379: $this->_parse('__dn', array('domain', 'singular', 'plural'));
380: $this->_parse('__dcn', array('domain', 'singular', 'plural', 'count', 'category'));
381:
382: $this->_parse('__x', array('context', 'singular'));
383: $this->_parse('__xn', array('context', 'singular', 'plural'));
384: $this->_parse('__dx', array('domain', 'context', 'singular'));
385: $this->_parse('__dxc', array('domain', 'context', 'singular', 'category'));
386: $this->_parse('__dxn', array('domain', 'context', 'singular', 'plural'));
387: $this->_parse('__dxcn', array('domain', 'context', 'singular', 'plural', 'count', 'category'));
388: $this->_parse('__xc', array('context', 'singular', 'category'));
389:
390: }
391: }
392:
393: 394: 395: 396: 397: 398: 399:
400: protected function _parse($functionName, $map) {
401: $count = 0;
402: $categories = array('LC_ALL', 'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY', 'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES');
403: $tokenCount = count($this->_tokens);
404:
405: while (($tokenCount - $count) > 1) {
406: $countToken = $this->_tokens[$count];
407: $firstParenthesis = $this->_tokens[$count + 1];
408: if (!is_array($countToken)) {
409: $count++;
410: continue;
411: }
412:
413: list($type, $string, $line) = $countToken;
414: if (($type == T_STRING) && ($string === $functionName) && ($firstParenthesis === '(')) {
415: $position = $count;
416: $depth = 0;
417:
418: while (!$depth) {
419: if ($this->_tokens[$position] === '(') {
420: $depth++;
421: } elseif ($this->_tokens[$position] === ')') {
422: $depth--;
423: }
424: $position++;
425: }
426:
427: $mapCount = count($map);
428: $strings = $this->_getStrings($position, $mapCount);
429:
430: if ($mapCount === count($strings)) {
431: extract(array_combine($map, $strings));
432: $category = isset($category) ? $category : 6;
433: $category = (int)$category;
434: $categoryName = $categories[$category];
435:
436: $domain = isset($domain) ? $domain : 'default';
437: $details = array(
438: 'file' => $this->_file,
439: 'line' => $line,
440: );
441: if (isset($plural)) {
442: $details['msgid_plural'] = $plural;
443: }
444: if (isset($context)) {
445: $details['msgctxt'] = $context;
446: }
447:
448: if ($categoryName !== 'LC_TIME') {
449: $this->_addTranslation($categoryName, $domain, $singular, $details);
450: }
451: } elseif (!is_array($this->_tokens[$count - 1]) || $this->_tokens[$count - 1][0] != T_FUNCTION) {
452: $this->_markerError($this->_file, $line, $functionName, $count);
453: }
454: }
455: $count++;
456: }
457: }
458:
459: 460: 461: 462: 463: 464:
465: protected function _extractValidationMessages() {
466: if (!$this->_extractValidation) {
467: return;
468: }
469:
470: $plugins = array(null);
471: if (empty($this->params['exclude-plugins'])) {
472: $plugins = array_merge($plugins, App::objects('plugin', null, false));
473: }
474: foreach ($plugins as $plugin) {
475: $this->_extractPluginValidationMessages($plugin);
476: }
477: }
478:
479: 480: 481: 482: 483: 484:
485: protected function _extractPluginValidationMessages($plugin = null) {
486: App::uses('AppModel', 'Model');
487: if (!empty($plugin)) {
488: if (!CakePlugin::loaded($plugin)) {
489: return;
490: }
491: App::uses($plugin . 'AppModel', $plugin . '.Model');
492: $plugin = $plugin . '.';
493: }
494: $models = App::objects($plugin . 'Model', null, false);
495:
496: foreach ($models as $model) {
497: App::uses($model, $plugin . 'Model');
498: $reflection = new ReflectionClass($model);
499: if (!$reflection->isSubClassOf('Model')) {
500: continue;
501: }
502: $properties = $reflection->getDefaultProperties();
503: $validate = $properties['validate'];
504: if (empty($validate)) {
505: continue;
506: }
507:
508: $file = $reflection->getFileName();
509: $domain = $this->_validationDomain;
510: if (!empty($properties['validationDomain'])) {
511: $domain = $properties['validationDomain'];
512: }
513: foreach ($validate as $field => $rules) {
514: $this->_processValidationRules($field, $rules, $file, $domain);
515: }
516: }
517: }
518:
519: 520: 521: 522: 523: 524: 525: 526: 527: 528: 529:
530: protected function _processValidationRules($field, $rules, $file, $domain, $category = 'LC_MESSAGES') {
531: if (!is_array($rules)) {
532: return;
533: }
534:
535: $dims = Hash::dimensions($rules);
536: if ($dims === 1 || ($dims === 2 && isset($rules['message']))) {
537: $rules = array($rules);
538: }
539:
540: foreach ($rules as $rule => $validateProp) {
541: $msgid = null;
542: if (isset($validateProp['message'])) {
543: if (is_array($validateProp['message'])) {
544: $msgid = $validateProp['message'][0];
545: } else {
546: $msgid = $validateProp['message'];
547: }
548: } elseif (is_string($rule)) {
549: $msgid = $rule;
550: }
551: if ($msgid) {
552: $msgid = $this->_formatString(sprintf("'%s'", $msgid));
553: $details = array(
554: 'file' => $file,
555: 'line' => 'validation for field ' . $field
556: );
557: $this->_addTranslation($category, $domain, $msgid, $details);
558: }
559: }
560: }
561:
562: 563: 564: 565: 566:
567: protected function _buildFiles() {
568: $paths = $this->_paths;
569: $paths[] = realpath(APP) . DS;
570: foreach ($this->_translations as $category => $domains) {
571: foreach ($domains as $domain => $translations) {
572: foreach ($translations as $msgid => $contexts) {
573: foreach ($contexts as $context => $details) {
574: $plural = $details['msgid_plural'];
575: $files = $details['references'];
576: $occurrences = array();
577: foreach ($files as $file => $lines) {
578: $lines = array_unique($lines);
579: $occurrences[] = $file . ':' . implode(';', $lines);
580: }
581: $occurrences = implode("\n#: ", $occurrences);
582: $header = '#: ' . str_replace(DS, '/', str_replace($paths, '', $occurrences)) . "\n";
583:
584: $sentence = '';
585: if ($context) {
586: $sentence .= "msgctxt \"{$context}\"\n";
587: }
588: if ($plural === false) {
589: $sentence .= "msgid \"{$msgid}\"\n";
590: $sentence .= "msgstr \"\"\n\n";
591: } else {
592: $sentence .= "msgid \"{$msgid}\"\n";
593: $sentence .= "msgid_plural \"{$plural}\"\n";
594: $sentence .= "msgstr[0] \"\"\n";
595: $sentence .= "msgstr[1] \"\"\n\n";
596: }
597:
598: $this->_store($category, $domain, $header, $sentence);
599: if (($category !== 'LC_MESSAGES' || $domain !== 'default') && $this->_merge) {
600: $this->_store('LC_MESSAGES', 'default', $header, $sentence);
601: }
602: }
603: }
604: }
605: }
606: }
607:
608: 609: 610: 611: 612: 613: 614: 615: 616:
617: protected function _store($category, $domain, $header, $sentence) {
618: if (!isset($this->_storage[$category])) {
619: $this->_storage[$category] = array();
620: }
621: if (!isset($this->_storage[$category][$domain])) {
622: $this->_storage[$category][$domain] = array();
623: }
624: if (!isset($this->_storage[$category][$domain][$sentence])) {
625: $this->_storage[$category][$domain][$sentence] = $header;
626: } else {
627: $this->_storage[$category][$domain][$sentence] .= $header;
628: }
629: }
630:
631: 632: 633: 634: 635:
636: protected function _writeFiles() {
637: $overwriteAll = false;
638: if (!empty($this->params['overwrite'])) {
639: $overwriteAll = true;
640: }
641: foreach ($this->_storage as $category => $domains) {
642: foreach ($domains as $domain => $sentences) {
643: $output = $this->_writeHeader();
644: foreach ($sentences as $sentence => $header) {
645: $output .= $header . $sentence;
646: }
647:
648: $filename = $domain . '.pot';
649: if ($category === 'LC_MESSAGES') {
650: $File = new File($this->_output . $filename);
651: } else {
652: new Folder($this->_output . $category, true);
653: $File = new File($this->_output . $category . DS . $filename);
654: }
655: $response = '';
656: while ($overwriteAll === false && $File->exists() && strtoupper($response) !== 'Y') {
657: $this->out();
658: $response = $this->in(
659: __d('cake_console', 'Error: %s already exists in this location. Overwrite? [Y]es, [N]o, [A]ll', $filename),
660: array('y', 'n', 'a'),
661: 'y'
662: );
663: if (strtoupper($response) === 'N') {
664: $response = '';
665: while (!$response) {
666: $response = $this->in(__d('cake_console', "What would you like to name this file?"), null, 'new_' . $filename);
667: $File = new File($this->_output . $response);
668: $filename = $response;
669: }
670: } elseif (strtoupper($response) === 'A') {
671: $overwriteAll = true;
672: }
673: }
674: $File->write($output);
675: $File->close();
676: }
677: }
678: }
679:
680: 681: 682: 683: 684:
685: protected function _writeHeader() {
686: $output = "# LANGUAGE translation of CakePHP Application\n";
687: $output .= "# Copyright YEAR NAME <EMAIL@ADDRESS>\n";
688: $output .= "#\n";
689: $output .= "#, fuzzy\n";
690: $output .= "msgid \"\"\n";
691: $output .= "msgstr \"\"\n";
692: $output .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n";
693: $output .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n";
694: $output .= "\"Last-Translator: NAME <EMAIL@ADDRESS>\\n\"\n";
695: $output .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\n\"\n";
696: $output .= "\"MIME-Version: 1.0\\n\"\n";
697: $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n";
698: $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n";
699: $output .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n\n";
700: return $output;
701: }
702:
703: 704: 705: 706: 707: 708: 709:
710: protected function _getStrings(&$position, $target) {
711: $strings = array();
712: $count = count($strings);
713: while ($count < $target && ($this->_tokens[$position] === ',' || $this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position][0] == T_LNUMBER)) {
714: $count = count($strings);
715: if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING && $this->_tokens[$position + 1] === '.') {
716: $string = '';
717: while ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position] === '.') {
718: if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) {
719: $string .= $this->_formatString($this->_tokens[$position][1]);
720: }
721: $position++;
722: }
723: $strings[] = $string;
724: } elseif ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) {
725: $strings[] = $this->_formatString($this->_tokens[$position][1]);
726: } elseif ($this->_tokens[$position][0] == T_LNUMBER) {
727: $strings[] = $this->_tokens[$position][1];
728: }
729: $position++;
730: }
731: return $strings;
732: }
733:
734: 735: 736: 737: 738: 739:
740: protected function _formatString($string) {
741: $quote = substr($string, 0, 1);
742: $string = substr($string, 1, -1);
743: if ($quote === '"') {
744: $string = stripcslashes($string);
745: } else {
746: $string = strtr($string, array("\\'" => "'", "\\\\" => "\\"));
747: }
748: $string = str_replace("\r\n", "\n", $string);
749: return addcslashes($string, "\0..\37\\\"");
750: }
751:
752: 753: 754: 755: 756: 757: 758: 759: 760:
761: protected function _markerError($file, $line, $marker, $count) {
762: $this->err(__d('cake_console', "Invalid marker content in %s:%s\n* %s(", $file, $line, $marker));
763: $count += 2;
764: $tokenCount = count($this->_tokens);
765: $parenthesis = 1;
766:
767: while ((($tokenCount - $count) > 0) && $parenthesis) {
768: if (is_array($this->_tokens[$count])) {
769: $this->err($this->_tokens[$count][1], false);
770: } else {
771: $this->err($this->_tokens[$count], false);
772: if ($this->_tokens[$count] === '(') {
773: $parenthesis++;
774: }
775:
776: if ($this->_tokens[$count] === ')') {
777: $parenthesis--;
778: }
779: }
780: $count++;
781: }
782: $this->err("\n", true);
783: }
784:
785: 786: 787: 788: 789:
790: protected function _searchFiles() {
791: $pattern = false;
792: if (!empty($this->_exclude)) {
793: $exclude = array();
794: foreach ($this->_exclude as $e) {
795: if (DS !== '\\' && $e[0] !== DS) {
796: $e = DS . $e;
797: }
798: $exclude[] = preg_quote($e, '/');
799: }
800: $pattern = '/' . implode('|', $exclude) . '/';
801: }
802: foreach ($this->_paths as $path) {
803: $Folder = new Folder($path);
804: $files = $Folder->findRecursive('.*\.(php|ctp|thtml|inc|tpl)', true);
805: if (!empty($pattern)) {
806: foreach ($files as $i => $file) {
807: if (preg_match($pattern, $file)) {
808: unset($files[$i]);
809: }
810: }
811: $files = array_values($files);
812: }
813: $this->_files = array_merge($this->_files, $files);
814: }
815: }
816:
817: 818: 819: 820: 821: 822:
823: protected function _isExtractingApp() {
824: return $this->_paths === array(APP);
825: }
826:
827: 828: 829: 830: 831: 832:
833: protected function _isPathUsable($path) {
834: return is_dir($path) && is_writable($path);
835: }
836: }
837: