1: <?php
2:
3: /**
4: * ProcessWire Base Class "Wire"
5: *
6: * Classes that descend from this have access to a $fuel property and fuel() method containing all of ProcessWire's objects.
7: * Descending classes can specify which methods should be "hookable" by precending the method name with 3 underscores: "___".
8: * This class provides the interface for tracking changes to object properties.
9: * message() and error() methods are provided for this class to provide any text notices.
10: *
11: * ProcessWire 2.x
12: * Copyright (C) 2010 by Ryan Cramer
13: * Licensed under GNU/GPL v2, see LICENSE.TXT
14: *
15: * http://www.processwire.com
16: * http://www.ryancramer.com
17: *
18: */
19:
20: abstract class Wire implements TrackChanges {
21:
22: /**
23: * Fuel holds references to other ProcessWire system objects. It is an instance of the Fuel class.
24: *
25: */
26: protected static $fuel = null;
27:
28: /**
29: * When a hook is specified, there are a few options which can be overridden: This array outlines those options and the defaults.
30: *
31: * - type: may be either 'method' or 'property'. If property, then it will respond to $obj->property rather than $obj->method().
32: * - before: execute the hook before the method call? Not applicable if 'type' is 'property'.
33: * - after: execute the hook after the method call? (allows modification of return value). Not applicable if 'type' is 'property'.
34: * - priority: a number determining the priority of a hook, where lower numbers are executed before higher numbers.
35: * - allInstances: attach the hook to all instances of this object? (store in staticHooks rather than localHooks). Set automatically, but you may still use in some instances.
36: * - fromClass: the name of the class containing the hooked method, if not the object where addHook was executed. Set automatically, but you may still use in some instances.
37: *
38: */
39: protected static $defaultHookOptions = array(
40: 'type' => 'method',
41: 'before' => false,
42: 'after' => true,
43: 'priority' => 100,
44: 'allInstances' => false,
45: 'fromClass' => '',
46: );
47:
48: /**
49: * Static hooks are applicable to all instances of the descending class.
50: *
51: * This array holds references to those static hooks, and is shared among all classes descending from Wire.
52: * It is for internal use only. See also self::$defaultHookOptions[allInstances].
53: *
54: */
55: protected static $staticHooks = array();
56:
57: /**
58: * Hooks that are local to this instance of the class only.
59: *
60: */
61: protected $localHooks = array();
62:
63: /**
64: * A static cache of all hook method/property names for an optimization.
65: *
66: * Hooked methods end with '()' while hooked properties don't.
67: *
68: * This does not distinguish which instance it was added to or whether it was removed.
69: * But will use keys in the form 'fromClass::method' (with value 'method') in cases where a fromClass was specified.
70: * This cache exists primarily to gain some speed in our __get and __call methods.
71: *
72: */
73: protected static $hookMethodCache = array();
74:
75:
76: /**
77: * Cached name of this class from the className() method
78: *
79: */
80: private $className = '';
81:
82: /**
83: * Is change tracking turned on?
84: *
85: */
86: protected $trackChanges = false;
87:
88: /**
89: * Array containing the names of properties that were changed while change tracking was ON.
90: *
91: */
92: protected $changes = array();
93:
94: /**
95: * Whether this class may use fuel variables in local scope, like $this->item
96: *
97: */
98: protected $useFuel = true;
99:
100: /**
101: * Add fuel to all classes descending from Wire
102: *
103: * @param string $name
104: * @param mixed $value
105: *
106: */
107: public static function setFuel($name, $value) {
108: if(is_null(self::$fuel)) self::$fuel = new Fuel();
109: self::$fuel->set($name, $value);
110: }
111:
112: /**
113: * Get the Fuel specified by $name or NULL if it doesn't exist
114: *
115: * @param string $name
116: * @return mixed|null
117: *
118: */
119: public static function getFuel($name) {
120: return self::$fuel->$name;
121: }
122:
123: /**
124: * Returns an iterable Fuel object of all Fuel currently loaded
125: *
126: * @return Fuel
127: *
128: */
129: public static function getAllFuel() {
130: return self::$fuel;
131: }
132:
133: /**
134: * Get the Fuel specified by $name or NULL if it doesn't exist
135: *
136: * Alias for getFuel()
137: *
138: * @param string $name
139: * @return mixed|null
140: *
141: */
142: public function fuel($name) {
143: return self::$fuel->$name;
144: }
145:
146: /**
147: * Should fuel vars be scoped locally to this class instance?
148: *
149: * If so, you can do things like $this->fuelItem.
150: * If not, then you'd have to do $this->fuel('fuelItem').
151: *
152: * If you specify a value, it will set the value of useFuel to true or false.
153: * If you don't specify a value, the current value will be returned.
154: *
155: * Local fuel scope should be disabled in classes where it might cause any conflict with class vars.
156: *
157: * @param bool $useFuel Optional boolean to turn it on or off.
158: * @return bool Current value of $useFuel
159: *
160: */
161: public function useFuel($useFuel = null) {
162: if(!is_null($useFuel)) $this->useFuel = $useFuel ? true : false;
163: return $this->useFuel;
164: }
165:
166: /**
167: * Return this object's class name
168: *
169: * Note that it caches the class name in the $className object property to reduce overhead from calls to get_class().
170: *
171: * @return string
172: *
173: */
174: public function className() {
175: if(!$this->className) $this->className = get_class($this);
176: return $this->className;
177: }
178:
179: /**
180: * Get an object property by direct reference or NULL if it doesn't exist
181: *
182: * If not overridden, this is primarily used as a shortcut for the fuel() method.
183: *
184: * @param string $name
185: * @return mixed|null
186: *
187: */
188: public function __get($name) {
189:
190: if($name == 'fuel') return self::getAllFuel();
191: if($name == 'className') return $this->className();
192: if($this->useFuel()) if(!is_null(self::$fuel) && !is_null(self::$fuel->$name)) return self::$fuel->$name;
193:
194: if(self::isHooked($name)) { // potential property hook
195: $result = $this->runHooks($name, array(), 'property');
196: return $result['return'];
197: }
198:
199: return null;
200: }
201:
202: /**
203: * Unless overridden, classes descending from Wire return their class name when typecast as a string
204: *
205: */
206: public function __toString() {
207: return $this->className();
208: }
209:
210: /**
211: * Provides the gateway for calling hooks in ProcessWire
212: *
213: * When a non-existant method is called, this checks to see if any hooks have been defined and sends the call to them.
214: *
215: * Hooks are defined by preceding the "hookable" method in a descending class with 3 underscores, like __myMethod().
216: * When the API calls $myObject->myMethod(), it gets sent to $myObject->___myMethod() after any 'before' hooks have been called.
217: * Then after the ___myMethod() call, any "after" hooks are then called. "after" hooks have the opportunity to change the return value.
218: *
219: * Hooks can also be added for methods that don't actually exist in the class, allowing another class to add methods to this class.
220: *
221: * See the Wire::runHooks() method for the full implementation of hook calls.
222: *
223: * @param string $method
224: * @param array $arguments
225: * @return mixed
226: *
227: */
228: public function __call($method, $arguments) {
229: $result = $this->runHooks($method, $arguments);
230: if(!$result['methodExists'] && !$result['numHooksRun'])
231: throw new WireException("Method " . $this->className() . "::$method does not exist or is not callable in this context");
232: return $result['return'];
233: }
234:
235: /**
236: * Provides the implementation for calling hooks in ProcessWire
237: *
238: * Unlike __call, this method won't trigger an Exception if the hook and method don't exist.
239: * Instead it returns a result array containing information about the call.
240: *
241: * @param string $method Method or property to run hooks for.
242: * @param array $arguments Arguments passed to the method and hook.
243: * @param string $type May be either 'method' or 'property', depending on the type of call. Default is 'method'.
244: * @return array Returns an array with the following information:
245: * [return] => The value returned from the hook or NULL if no value returned or hook didn't exist.
246: * [numHooksRun] => The number of hooks that were actually run.
247: * [methodExists] => Did the hook method exist as a real method in the class? (i.e. with 3 underscores ___method).
248: * [replace] => Set by the hook at runtime if it wants to prevent execution of the original hooked method.
249: *
250: */
251: public function runHooks($method, $arguments, $type = 'method') {
252:
253: $result = array(
254: 'return' => null,
255: 'numHooksRun' => 0,
256: 'methodExists' => false,
257: 'replace' => false,
258: );
259:
260: $realMethod = "___$method";
261: if($type == 'method') $result['methodExists'] = method_exists($this, $realMethod);
262: if(!$result['methodExists'] && !self::isHooked($method . ($type == 'method' ? '()' : ''))) return $result; // exit quickly when we can
263:
264: $hooks = $this->getHooks();
265:
266: foreach(array('before', 'after') as $when) {
267:
268: if($type === 'method' && $when === 'after' && $result['replace'] !== true) {
269: if($result['methodExists']) $result['return'] = call_user_func_array(array($this, $realMethod), $arguments);
270: else $result['return'] = null;
271: }
272:
273: foreach($hooks as $priority => $hook) {
274:
275: if($hook['method'] !== $method) continue;
276: if(!$hook['options'][$when]) continue;
277:
278: $event = new HookEvent();
279: $event->object = $this;
280: $event->method = $method;
281: $event->arguments = $arguments;
282: $event->when = $when;
283: $event->return = $result['return'];
284: $event->id = $hook['id'];
285: $event->options = $hook['options'];
286:
287: $toObject = $hook['toObject'];
288: $toMethod = $hook['toMethod'];
289:
290: if(is_null($toObject)) $toMethod($event);
291: else $toObject->$toMethod($event);
292:
293: $result['numHooksRun']++;
294:
295: if($when == 'before') {
296: $arguments = $event->arguments;
297: $result['replace'] = $event->replace === true ? true : false;
298: if($result['replace']) $result['return'] = $event->return;
299: }
300:
301: if($when == 'after') $result['return'] = $event->return;
302: }
303:
304: }
305:
306: return $result;
307: }
308:
309: /**
310: * Return all hooks associated with this class instance or method (if specified)
311: *
312: * @param string $method Optional method that hooks will be limited to. Or specify '*' to return all hooks everywhere.
313: * @return array
314: *
315: */
316: public function getHooks($method = '') {
317:
318: $hooks = $this->localHooks;
319:
320: foreach(self::$staticHooks as $className => $staticHooks) {
321: // join in any related static hooks to the instance hooks
322: if($this instanceof $className || $method == '*') {
323: // TODO determine if the local vs static priority level may be damaged by the array_merge
324: $hooks = array_merge($hooks, $staticHooks);
325: }
326: }
327:
328: if($method && $method != '*') {
329: $methodHooks = array();
330: foreach($hooks as $priority => $hook) {
331: if($hook['method'] == $method) $methodHooks[$priority] = $hook;
332: }
333: $hooks = $methodHooks;
334: }
335:
336: return $hooks;
337: }
338:
339: /**
340: * Returns true if the method/property hooked, false if it isn't.
341: *
342: * This is for optimization use. It does not distinguish about class or instance.
343: *
344: * If checking for a hooked method, it should be in the form "method()".
345: * If checking for a hooked property, it should be in the form "property".
346: *
347: */
348: static protected function isHooked($method) {
349: if(array_key_exists($method, self::$hookMethodCache)) return true; // fromClass::method() or fromClass::property
350: if(in_array($method, self::$hookMethodCache)) return true; // method() or property
351: return false;
352: }
353:
354: /**
355: * Hook a function/method to a hookable method call in this object
356: *
357: * Hookable method calls are methods preceded by three underscores.
358: * You may also specify a method that doesn't exist already in the class
359: * The hook method that you define may be part of a class or a globally scoped function.
360: *
361: * @param string $method Method name to hook into, NOT including the three preceding underscores. May also be Class::Method for same result as using the fromClass option.
362: * @param object|null $toObject Object to call $toMethod from, or null if $toMethod is a function outside of an object
363: * @param string $toMethod Method from $toObject, or function name to call on a hook event
364: * @param array $options See self::$defaultHookOptions at the beginning of this class
365: * @return string A special Hook ID that should be retained if you need to remove the hook later
366: *
367: */
368: public function addHook($method, $toObject, $toMethod, $options = array()) {
369:
370: if(substr($method, 0, 3) == '___') throw new WireException("You must specify hookable methods without the 3 preceding underscores");
371: if(method_exists($this, $method)) throw new WireException("Method " . $this->className() . "::$method is not hookable");
372:
373: $options = array_merge(self::$defaultHookOptions, $options);
374: if(strpos($method, '::')) list($options['fromClass'], $method) = explode('::', $method);
375:
376: if($options['allInstances'] || $options['fromClass']) {
377: // hook all instances of this class
378: $hookClass = $options['fromClass'] ? $options['fromClass'] : $this->className();
379: if(!isset(self::$staticHooks[$hookClass])) self::$staticHooks[$hookClass] = array();
380: $hooks =& self::$staticHooks[$hookClass];
381:
382: } else {
383: // hook only this instance
384: $hookClass = '';
385: $hooks =& $this->localHooks;
386: }
387:
388: $priority = (int) $options['priority'];
389: while(isset($hooks[$priority])) $priority++;
390: $id = "$hookClass:$priority";
391:
392: $hooks[$priority] = array(
393: 'id' => $id,
394: 'method' => $method,
395: 'toObject' => $toObject,
396: 'toMethod' => $toMethod,
397: 'options' => $options,
398: );
399:
400: // cacheValue is just the method() or property, cacheKey includes optional fromClass::
401: $cacheValue = $options['type'] == 'method' ? "$method()" : "$method";
402: $cacheKey = ($options['fromClass'] ? $options['fromClass'] . '::' : '') . $cacheValue;
403: self::$hookMethodCache[$cacheKey] = $cacheValue;
404:
405: ksort($hooks); // sort by priority
406: return $id;
407: }
408:
409: /**
410: * Shortcut to the addHook() method which adds a hook to be executed before the hooked method.
411: *
412: * This is the same as calling addHook with the 'before' option set the $options array.
413: *
414: * @param string $method Method name to hook into, NOT including the three preceding underscores
415: * @param object|null $toObject Object to call $toMethod from, or null if $toMethod is a function outside of an object
416: * @param string $toMethod Method from $toObject, or function name to call on a hook event
417: * @param array $options See self::$defaultHookOptions at the beginning of this class
418: * @return string A special Hook ID that should be retained if you need to remove the hook later
419: *
420: */
421: public function addHookBefore($method, $toObject, $toMethod, $options = array()) {
422: $options['before'] = true;
423: if(!isset($options['after'])) $options['after'] = false;
424: return $this->addHook($method, $toObject, $toMethod, $options);
425: }
426:
427: /**
428: * Shortcut to the addHook() method which adds a hook to be executed after the hooked method.
429: *
430: * This is the same as calling addHook with the 'after' option set the $options array.
431: *
432: * @param string $method Method name to hook into, NOT including the three preceding underscores
433: * @param object|null $toObject Object to call $toMethod from, or null if $toMethod is a function outside of an object
434: * @param string $toMethod Method from $toObject, or function name to call on a hook event
435: * @param array $options See self::$defaultHookOptions at the beginning of this class
436: * @return string A special Hook ID that should be retained if you need to remove the hook later
437: *
438: */
439: public function addHookAfter($method, $toObject, $toMethod, $options = array()) {
440: $options['after'] = true;
441: if(!isset($options['before'])) $options['before'] = false;
442: return $this->addHook($method, $toObject, $toMethod, $options);
443: }
444:
445: /**
446: * Shortcut to the addHook() method which adds a hook to be executed as an object property.
447: *
448: * i.e. $obj->property; in addition to $obj->property();
449: *
450: * This is the same as calling addHook with the 'type' option set to 'property' in the $options array.
451: * Note that descending classes that override __get must call getHook($property) and/or runHook($property).
452: *
453: * @param string $method Method name to hook into, NOT including the three preceding underscores
454: * @param object|null $toObject Object to call $toMethod from, or null if $toMethod is a function outside of an object
455: * @param string $toMethod Method from $toObject, or function name to call on a hook event
456: * @param array $options See self::$defaultHookOptions at the beginning of this class
457: * @return string A special Hook ID that should be retained if you need to remove the hook later
458: *
459: */
460: public function addHookProperty($property, $toObject, $toMethod, $options = array()) {
461: $options['type'] = 'property';
462: return $this->addHook($property, $toObject, $toMethod, $options);
463: }
464:
465: /**
466: * Given a Hook ID provided by addHook() this removes the hook
467: *
468: * @param string $hookId
469: * @return this
470: *
471: */
472: public function removeHook($hookId) {
473: list($hookClass, $priority) = explode(':', $hookId);
474: if(!$hookClass) unset($this->localHooks[$priority]);
475: else unset(self::$staticHooks[$hookClass][$priority]);
476: return $this;
477: }
478:
479:
480: /**
481: * Has the given property changed?
482: *
483: * Applicable only for properties you are tracking while $trackChanges is true.
484: *
485: * @param string $what Name of property, or if left blank, check if any properties have changed.
486: * @return bool
487: *
488: */
489: public function isChanged($what = '') {
490: if(!$what) return count($this->changes) > 0;
491: return in_array($what, $this->changes);
492: }
493:
494: /**
495: * Hookable method that is called whenever a property has changed while self::$trackChanges is true
496: *
497: * Enables hooks to monitor changes to the object.
498: *
499: */
500: public function ___changed($what) {
501: // for hooks to listen to
502: }
503:
504: /**
505: * Track a change to a property in this object
506: *
507: * The change will only be recorded if self::$trackChanges is true.
508: *
509: * @param string $what Name of property that changed
510: * @return this
511: *
512: */
513: public function trackChange($what) {
514: if($this->trackChanges) {
515: $this->changes[] = $what;
516: $this->changed($what);
517: }
518: return $this;
519: }
520:
521: /**
522: * Untrack a change to a property in this object
523: *
524: * @param string $what Name of property that you want to remove it's change being tracked
525: * @return this
526: *
527: */
528: public function untrackChange($what) {
529: $key = array_search($what, $this->changes);
530: if($key !== false) unset($this->changes[$key]);
531: return $this;
532: }
533:
534: /**
535: * Turn change tracking ON or OFF
536: *
537: * @param bool $trackChanges True to turn on, false to turn off. If not specified, true is assumed.
538: * @return this
539: *
540: */
541: public function setTrackChanges($trackChanges = true) {
542: $this->trackChanges = $trackChanges;
543: return $this;
544: }
545:
546: /**
547: * Returns true if chagne tracking is on, or false if it's not.
548: *
549: * @return bool
550: *
551: */
552: public function trackChanges() {
553: return $this->trackChanges;
554: }
555:
556: /**
557: * Clears out any tracked changes and turns change tracking ON or OFF
558: *
559: * @param bool $trackChanges True to turn change tracking ON, or false to turn OFF. Default of true is assumed.
560: * @return this
561: *
562: */
563: public function resetTrackChanges($trackChanges = true) {
564: $this->changes = array();
565: return $this->setTrackChanges($trackChanges);
566: }
567:
568: /**
569: * Return an array of properties that have changed while change tracking was on.
570: *
571: * @return array
572: *
573: */
574: public function getChanges() {
575: return $this->changes;
576: }
577:
578: /**
579: * Record an informational or 'success' message in the system-wide notices.
580: *
581: * This method automatically identifies the message as coming from this class.
582: *
583: * @param string $text
584: * @param flags int See Notices::flags
585: * @return this
586: *
587: */
588: public function message($text, $flags = 0) {
589: $notice = new NoticeMessage($text, $flags);
590: $notice->class = $this->className();
591: $this->fuel('notices')->add($notice);
592: return $this;
593: }
594:
595: /**
596: * Record an non-fatal error message in the system-wide notices.
597: *
598: * This method automatically identifies the error as coming from this class.
599: *
600: * Fatal errors should still throw a WireException (or class derived from it)
601: *
602: * @param string $text
603: * @param flags int See Notices::flags
604: * @return this
605: *
606: */
607: public function error($text, $flags = 0) {
608: $notice = new NoticeError($text, $flags);
609: $notice->class = $this->className();
610: $this->fuel('notices')->add($notice);
611: return $this;
612: }
613:
614: /**
615: * Translate the given text string into the current language if available.
616: *
617: * If not available, or if the current language is the native language, then it returns the text as is.
618: *
619: * @param string $text Text string to translate
620: * @return string
621: *
622: */
623: public function _($text) {
624: return __($text, $this);
625: }
626:
627: /**
628: * Perform a language translation in a specific context
629: *
630: * Used when to text strings might be the same in English, but different in other languages.
631: *
632: * @param string $text Text for translation.
633: * @param string $context Name of context
634: * @return string Translated text or original text if translation not available.
635: *
636: */
637: public function _x($text, $context) {
638: return _x($text, $context, $this);
639: }
640:
641: /**
642: * Perform a language translation with singular and plural versions
643: *
644: * @param string $textSingular Singular version of text (when there is 1 item)
645: * @param string $textPlural Plural version of text (when there are multiple items or 0 items)
646: * @return string Translated text or original text if translation not available.
647: *
648: */
649: public function _n($textSingular, $textPlural, $count) {
650: return _n($textSingular, $textPlural, $count, $this);
651: }
652:
653:
654: }
655:
656: