diff options
Diffstat (limited to 'OpenSim/Region/OptionalModules/World/AutoBackup')
-rw-r--r-- | OpenSim/Region/OptionalModules/World/AutoBackup/AutoBackupModule.cs | 540 |
1 files changed, 540 insertions, 0 deletions
diff --git a/OpenSim/Region/OptionalModules/World/AutoBackup/AutoBackupModule.cs b/OpenSim/Region/OptionalModules/World/AutoBackup/AutoBackupModule.cs new file mode 100644 index 0000000..ed21e41 --- /dev/null +++ b/OpenSim/Region/OptionalModules/World/AutoBackup/AutoBackupModule.cs | |||
@@ -0,0 +1,540 @@ | |||
1 | /* | ||
2 | * Copyright (c) Contributors, http://opensimulator.org/ | ||
3 | * See CONTRIBUTORS.TXT for a full list of copyright holders. | ||
4 | * | ||
5 | * Redistribution and use in source and binary forms, with or without | ||
6 | * modification, are permitted provided that the following conditions are met: | ||
7 | * * Redistributions of source code must retain the above copyright | ||
8 | * notice, this list of conditions and the following disclaimer. | ||
9 | * * Redistributions in binary form must reproduce the above copyright | ||
10 | * notice, this list of conditions and the following disclaimer in the | ||
11 | * documentation and/or other materials provided with the distribution. | ||
12 | * * Neither the name of the OpenSimulator Project nor the | ||
13 | * names of its contributors may be used to endorse or promote products | ||
14 | * derived from this software without specific prior written permission. | ||
15 | * | ||
16 | * THIS SOFTWARE IS PROVIDED BY THE DEVELOPERS ``AS IS'' AND ANY | ||
17 | * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED | ||
18 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||
19 | * DISCLAIMED. IN NO EVENT SHALL THE CONTRIBUTORS BE LIABLE FOR ANY | ||
20 | * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES | ||
21 | * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; | ||
22 | * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND | ||
23 | * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||
24 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS | ||
25 | * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
26 | */ | ||
27 | |||
28 | using System; | ||
29 | using System.IO; | ||
30 | using System.Timers; | ||
31 | using System.Diagnostics; | ||
32 | using System.Reflection; | ||
33 | using System.Collections.Generic; | ||
34 | using log4net; | ||
35 | using Nini; | ||
36 | using Nini.Config; | ||
37 | using OpenSim.Framework; | ||
38 | using OpenSim.Region.Framework.Interfaces; | ||
39 | |||
40 | |||
41 | /* | ||
42 | * Config Settings Documentation. | ||
43 | * EACH REGION in e.g. Regions/Regions.ini can have the following options: | ||
44 | * AutoBackup: True/False. Default: False. If True, activate auto backup functionality. | ||
45 | * This is the only required option for enabling auto-backup; the other options have sane defaults. | ||
46 | * If False, the auto-backup module becomes a no-op for the region, and all other AutoBackup* settings are ignored. | ||
47 | * AutoBackupInterval: Double, non-negative value. Default: 720 (12 hours). | ||
48 | * The number of minutes between each backup attempt. | ||
49 | * If a negative or zero value is given, it is equivalent to setting AutoBackup = False. | ||
50 | * AutoBackupBusyCheck: True/False. Default: True. | ||
51 | * If True, we will only take an auto-backup if a set of conditions are met. | ||
52 | * These conditions are heuristics to try and avoid taking a backup when the sim is busy. | ||
53 | * AutoBackupScript: String. Default: not specified (disabled). | ||
54 | * File path to an executable script or binary to run when an automatic backup is taken. | ||
55 | * The file should really be (Windows) an .exe or .bat, or (Linux/Mac) a shell script or binary. | ||
56 | * Trying to "run" directories, or things with weird file associations on Win32, might cause unexpected results! | ||
57 | * argv[1] of the executed file/script will be the file name of the generated OAR. | ||
58 | * If the process can't be spawned for some reason (file not found, no execute permission, etc), write a warning to the console. | ||
59 | * AutoBackupNaming: string. Default: Time. | ||
60 | * One of three strings (case insensitive): | ||
61 | * "Time": Current timestamp is appended to file name. An existing file will never be overwritten. | ||
62 | * "Sequential": A number is appended to the file name. So if RegionName_x.oar exists, we'll save to RegionName_{x+1}.oar next. An existing file will never be overwritten. | ||
63 | * "Overwrite": Always save to file named "${AutoBackupDir}/RegionName.oar", even if we have to overwrite an existing file. | ||
64 | * AutoBackupDir: String. Default: "." (the current directory). | ||
65 | * A directory (absolute or relative) where backups should be saved. | ||
66 | * */ | ||
67 | |||
68 | namespace OpenSim.Region.OptionalModules.World.AutoBackup | ||
69 | { | ||
70 | |||
71 | public enum NamingType | ||
72 | { | ||
73 | TIME, | ||
74 | SEQUENTIAL, | ||
75 | OVERWRITE | ||
76 | }; | ||
77 | |||
78 | public class AutoBackupModule : ISharedRegionModule, IRegionModuleBase | ||
79 | { | ||
80 | |||
81 | private static readonly ILog m_log = | ||
82 | LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); | ||
83 | |||
84 | //AutoBackupModuleState: Auto-Backup state for one region (scene). | ||
85 | public class AutoBackupModuleState | ||
86 | { | ||
87 | private readonly IScene m_scene; | ||
88 | private bool m_enabled = false; | ||
89 | private NamingType m_naming = NamingType.TIME; | ||
90 | private Timer m_timer = null; | ||
91 | private bool m_busycheck = true; | ||
92 | private string m_script = null; | ||
93 | private string m_dir = "."; | ||
94 | |||
95 | public AutoBackupModuleState(IScene scene) | ||
96 | { | ||
97 | m_scene = scene; | ||
98 | if(scene == null) | ||
99 | throw new NullReferenceException("Required parameter missing for AutoBackupModuleState constructor"); | ||
100 | } | ||
101 | |||
102 | public void SetEnabled(bool b) | ||
103 | { | ||
104 | m_enabled = b; | ||
105 | } | ||
106 | |||
107 | public bool GetEnabled() | ||
108 | { | ||
109 | return m_enabled; | ||
110 | } | ||
111 | |||
112 | public Timer GetTimer() | ||
113 | { | ||
114 | return m_timer; | ||
115 | } | ||
116 | |||
117 | public void SetTimer(Timer t) | ||
118 | { | ||
119 | m_timer = t; | ||
120 | } | ||
121 | |||
122 | public bool GetBusyCheck() | ||
123 | { | ||
124 | return m_busycheck; | ||
125 | } | ||
126 | |||
127 | public void SetBusyCheck(bool b) | ||
128 | { | ||
129 | m_busycheck = b; | ||
130 | } | ||
131 | |||
132 | |||
133 | public string GetScript() | ||
134 | { | ||
135 | return m_script; | ||
136 | } | ||
137 | |||
138 | public void SetScript(string s) | ||
139 | { | ||
140 | m_script = s; | ||
141 | } | ||
142 | |||
143 | public string GetBackupDir() | ||
144 | { | ||
145 | return m_dir; | ||
146 | } | ||
147 | |||
148 | public void SetBackupDir(string s) | ||
149 | { | ||
150 | m_dir = s; | ||
151 | } | ||
152 | |||
153 | public NamingType GetNamingType() | ||
154 | { | ||
155 | return m_naming; | ||
156 | } | ||
157 | |||
158 | public void SetNamingType(NamingType n) | ||
159 | { | ||
160 | m_naming = n; | ||
161 | } | ||
162 | } | ||
163 | |||
164 | //Save memory by setting low initial capacities. Minimizes impact in common cases of all regions using same interval, and instances hosting 1 ~ 4 regions. | ||
165 | //Also helps if you don't want AutoBackup at all | ||
166 | readonly Dictionary<IScene, AutoBackupModuleState> states = new Dictionary<IScene, AutoBackupModuleState>(4); | ||
167 | readonly Dictionary<double, Timer> timers = new Dictionary<double, Timer>(1); | ||
168 | readonly Dictionary<Timer, List<IScene>> timerMap = new Dictionary<Timer, List<IScene>>(1); | ||
169 | |||
170 | public AutoBackupModule () | ||
171 | { | ||
172 | |||
173 | } | ||
174 | |||
175 | #region IRegionModuleBase implementation | ||
176 | void IRegionModuleBase.Initialise (Nini.Config.IConfigSource source) | ||
177 | { | ||
178 | //I have no overall config settings to care about. | ||
179 | } | ||
180 | |||
181 | void IRegionModuleBase.Close () | ||
182 | { | ||
183 | //We don't want any timers firing while the sim's coming down; strange things may happen. | ||
184 | StopAllTimers(); | ||
185 | } | ||
186 | |||
187 | void IRegionModuleBase.AddRegion (Framework.Scenes.Scene scene) | ||
188 | { | ||
189 | //NO-OP. Wait for the region to be loaded. | ||
190 | } | ||
191 | |||
192 | void IRegionModuleBase.RemoveRegion (Framework.Scenes.Scene scene) | ||
193 | { | ||
194 | AutoBackupModuleState abms = states[scene]; | ||
195 | Timer timer = abms.GetTimer(); | ||
196 | List<IScene> list = timerMap[timer]; | ||
197 | list.Remove(scene); | ||
198 | if(list.Count == 0) | ||
199 | { | ||
200 | timerMap.Remove(timer); | ||
201 | timers.Remove(timer.Interval); | ||
202 | timer.Close(); | ||
203 | } | ||
204 | } | ||
205 | |||
206 | void IRegionModuleBase.RegionLoaded (Framework.Scenes.Scene scene) | ||
207 | { | ||
208 | //This really ought not to happen, but just in case, let's pretend it didn't... | ||
209 | if(scene == null) | ||
210 | return; | ||
211 | |||
212 | AutoBackupModuleState st = new AutoBackupModuleState(scene); | ||
213 | states.Add(scene, st); | ||
214 | |||
215 | //Read the config settings and set variables. | ||
216 | IConfig config = scene.Config.Configs[scene.RegionInfo.RegionName]; | ||
217 | st.SetEnabled(config.GetBoolean("AutoBackup", false)); | ||
218 | if(!st.GetEnabled()) //If you don't want AutoBackup, we stop. | ||
219 | return; | ||
220 | |||
221 | //Borrow an existing timer if one exists for the same interval; otherwise, make a new one. | ||
222 | double interval = config.GetDouble("AutoBackupInterval", 720); | ||
223 | if(timers.ContainsKey(interval)) | ||
224 | { | ||
225 | st.SetTimer(timers[interval]); | ||
226 | } | ||
227 | else | ||
228 | { | ||
229 | st.SetTimer(new Timer(interval)); | ||
230 | timers.Add(interval, st.GetTimer()); | ||
231 | st.GetTimer().Elapsed += HandleElapsed; | ||
232 | } | ||
233 | |||
234 | //Add the current region to the list of regions tied to this timer. | ||
235 | if(timerMap.ContainsKey(st.GetTimer())) | ||
236 | { | ||
237 | timerMap[st.GetTimer()].Add(scene); | ||
238 | } | ||
239 | else | ||
240 | { | ||
241 | List<IScene> scns = new List<IScene>(1); | ||
242 | timerMap.Add(st.GetTimer(), scns); | ||
243 | } | ||
244 | |||
245 | st.SetBusyCheck(config.GetBoolean("AutoBackupBusyCheck", true)); | ||
246 | |||
247 | //Set file naming algorithm | ||
248 | string namingtype = config.GetString("AutoBackupNaming", "Time"); | ||
249 | if(namingtype.Equals("Time", StringComparison.CurrentCultureIgnoreCase)) | ||
250 | { | ||
251 | st.SetNamingType(NamingType.TIME); | ||
252 | } | ||
253 | else if(namingtype.Equals("Sequential", StringComparison.CurrentCultureIgnoreCase)) | ||
254 | { | ||
255 | st.SetNamingType(NamingType.SEQUENTIAL); | ||
256 | } | ||
257 | else if(namingtype.Equals("Overwrite", StringComparison.CurrentCultureIgnoreCase)) | ||
258 | { | ||
259 | st.SetNamingType(NamingType.OVERWRITE); | ||
260 | } | ||
261 | else | ||
262 | { | ||
263 | m_log.Warn("Unknown naming type specified for region " + scene.RegionInfo.RegionName + ": " + namingtype); | ||
264 | st.SetNamingType(NamingType.TIME); | ||
265 | } | ||
266 | |||
267 | st.SetScript(config.GetString("AutoBackupScript", null)); | ||
268 | st.SetBackupDir(config.GetString("AutoBackupDir", ".")); | ||
269 | |||
270 | //Let's give the user *one* convenience and auto-mkdir | ||
271 | if(st.GetBackupDir() != ".") | ||
272 | { | ||
273 | try | ||
274 | { | ||
275 | DirectoryInfo dirinfo = new DirectoryInfo(st.GetBackupDir()); | ||
276 | if(!dirinfo.Exists) | ||
277 | { | ||
278 | dirinfo.Create(); | ||
279 | } | ||
280 | } | ||
281 | catch(Exception e) | ||
282 | { | ||
283 | m_log.Warn("BAD NEWS. You won't be able to save backups to directory " + st.GetBackupDir() + | ||
284 | " because it doesn't exist or there's a permissions issue with it. Here's the exception.", e); | ||
285 | } | ||
286 | } | ||
287 | } | ||
288 | |||
289 | void HandleElapsed (object sender, ElapsedEventArgs e) | ||
290 | { | ||
291 | bool heuristicsRun = false; | ||
292 | bool heuristicsPassed = false; | ||
293 | foreach(IScene scene in timerMap[(Timer)sender]) | ||
294 | { | ||
295 | AutoBackupModuleState state = states[scene]; | ||
296 | bool heuristics = state.GetBusyCheck(); | ||
297 | |||
298 | //Fast path: heuristics are on; already ran em; and sim is fine; OR, no heuristics for the region. | ||
299 | if((heuristics && heuristicsRun && heuristicsPassed) | ||
300 | || !heuristics) | ||
301 | { | ||
302 | IRegionArchiverModule iram = scene.RequestModuleInterface<IRegionArchiverModule>(); | ||
303 | string savePath = BuildOarPath(scene.RegionInfo.RegionName, state.GetBackupDir(), state.GetNamingType()); | ||
304 | if(savePath == null) | ||
305 | { | ||
306 | m_log.Warn("savePath is null in HandleElapsed"); | ||
307 | continue; | ||
308 | } | ||
309 | iram.ArchiveRegion(savePath, null); | ||
310 | ExecuteScript(state.GetScript(), savePath); | ||
311 | } | ||
312 | //Heuristics are on; ran but we're too busy -- keep going. Maybe another region will have heuristics off! | ||
313 | else if(heuristics && heuristicsRun && !heuristicsPassed) | ||
314 | { | ||
315 | continue; | ||
316 | } | ||
317 | //Logical Deduction: heuristics are on but haven't been run | ||
318 | else | ||
319 | { | ||
320 | heuristicsPassed = RunHeuristics(); | ||
321 | heuristicsRun = true; | ||
322 | if(!heuristicsPassed) | ||
323 | continue; | ||
324 | } | ||
325 | } | ||
326 | } | ||
327 | |||
328 | string IRegionModuleBase.Name { | ||
329 | get { | ||
330 | return "AutoBackupModule"; | ||
331 | } | ||
332 | } | ||
333 | |||
334 | Type IRegionModuleBase.ReplaceableInterface { | ||
335 | get { | ||
336 | return null; | ||
337 | } | ||
338 | } | ||
339 | |||
340 | #endregion | ||
341 | #region ISharedRegionModule implementation | ||
342 | void ISharedRegionModule.PostInitialise () | ||
343 | { | ||
344 | //I don't care right now. | ||
345 | } | ||
346 | |||
347 | #endregion | ||
348 | |||
349 | //Is this even needed? | ||
350 | public bool IsSharedModule | ||
351 | { | ||
352 | get { return true; } | ||
353 | } | ||
354 | |||
355 | private string BuildOarPath(string regionName, string baseDir, NamingType naming) | ||
356 | { | ||
357 | FileInfo path = null; | ||
358 | switch(naming) | ||
359 | { | ||
360 | case NamingType.OVERWRITE: | ||
361 | path = new FileInfo(baseDir + Path.DirectorySeparatorChar + regionName); | ||
362 | return path.FullName; | ||
363 | case NamingType.TIME: | ||
364 | path = new FileInfo(baseDir + Path.DirectorySeparatorChar + regionName + GetTimeString() + ".oar"); | ||
365 | return path.FullName; | ||
366 | case NamingType.SEQUENTIAL: | ||
367 | path = new FileInfo(baseDir + Path.DirectorySeparatorChar + regionName + "_" + GetNextFile(baseDir, regionName) + ".oar"); | ||
368 | return path.FullName; | ||
369 | default: | ||
370 | m_log.Warn("VERY BAD: Unhandled case element " + naming.ToString()); | ||
371 | break; | ||
372 | } | ||
373 | |||
374 | return path.FullName; | ||
375 | } | ||
376 | |||
377 | //Welcome to the TIME STRING. 4 CORNER INTEGERS, CUBES 4 QUAD MEMORY -- No 1 Integer God. | ||
378 | //(Terrible reference to <timecube.com>) | ||
379 | //This format may turn out to be too unwieldy to keep... | ||
380 | //Besides, that's what ctimes are for. But then how do I name each file uniquely without using a GUID? | ||
381 | //Sequential numbers, right? Ugh. Almost makes TOO much sense. | ||
382 | private string GetTimeString() | ||
383 | { | ||
384 | StringWriter sw = new StringWriter(); | ||
385 | sw.Write("_"); | ||
386 | DateTime now = DateTime.Now; | ||
387 | sw.Write(now.Year); | ||
388 | sw.Write("y_"); | ||
389 | sw.Write(now.Month); | ||
390 | sw.Write("M_"); | ||
391 | sw.Write(now.Day); | ||
392 | sw.Write("d_"); | ||
393 | sw.Write(now.Hour); | ||
394 | sw.Write("h_"); | ||
395 | sw.Write(now.Minute); | ||
396 | sw.Write("m_"); | ||
397 | sw.Write(now.Second); | ||
398 | sw.Write("s"); | ||
399 | sw.Flush(); | ||
400 | string output = sw.ToString(); | ||
401 | sw.Close(); | ||
402 | return output; | ||
403 | } | ||
404 | |||
405 | //Get the next logical file name | ||
406 | //I really shouldn't put fields here, but for now.... ;) | ||
407 | private string m_dirName = null; | ||
408 | private string m_regionName = null; | ||
409 | private string GetNextFile(string dirName, string regionName) | ||
410 | { | ||
411 | FileInfo uniqueFile = null; | ||
412 | m_dirName = dirName; | ||
413 | m_regionName = regionName; | ||
414 | long biggestExistingFile = HalfIntervalMaximize(1, FileExistsTest); | ||
415 | biggestExistingFile++; //We don't want to overwrite the biggest existing file; we want to write to the NEXT biggest. | ||
416 | |||
417 | uniqueFile = new FileInfo(m_dirName + Path.DirectorySeparatorChar + m_regionName + "_" + biggestExistingFile + ".oar"); | ||
418 | if(uniqueFile.Exists) | ||
419 | { | ||
420 | //Congratulations, your strange deletion patterns fooled my half-interval search into picking an existing file! | ||
421 | //Now you get to pay the performance cost :) | ||
422 | uniqueFile = UniqueFileSearchLinear(biggestExistingFile); | ||
423 | } | ||
424 | |||
425 | return uniqueFile.FullName; | ||
426 | } | ||
427 | |||
428 | private bool RunHeuristics() | ||
429 | { | ||
430 | return true; | ||
431 | } | ||
432 | |||
433 | private void ExecuteScript(string scriptName, string savePath) | ||
434 | { | ||
435 | //Fast path out | ||
436 | if(scriptName == null || scriptName.Length <= 0) | ||
437 | return; | ||
438 | |||
439 | try | ||
440 | { | ||
441 | FileInfo fi = new FileInfo(scriptName); | ||
442 | if(fi.Exists) | ||
443 | { | ||
444 | ProcessStartInfo psi = new ProcessStartInfo(scriptName); | ||
445 | psi.Arguments = savePath; | ||
446 | psi.CreateNoWindow = true; | ||
447 | Process proc = Process.Start(psi); | ||
448 | proc.ErrorDataReceived += HandleProcErrorDataReceived; | ||
449 | } | ||
450 | } | ||
451 | catch(Exception e) | ||
452 | { | ||
453 | m_log.Warn("Exception encountered when trying to run script for oar backup " + savePath, e); | ||
454 | } | ||
455 | } | ||
456 | |||
457 | void HandleProcErrorDataReceived (object sender, DataReceivedEventArgs e) | ||
458 | { | ||
459 | m_log.Warn("ExecuteScript hook " + ((Process)sender).ProcessName + " is yacking on stderr: " + e.Data); | ||
460 | } | ||
461 | |||
462 | private void StopAllTimers() | ||
463 | { | ||
464 | foreach(Timer t in timerMap.Keys) | ||
465 | { | ||
466 | t.Close(); | ||
467 | } | ||
468 | } | ||
469 | |||
470 | /* Find the largest value for which the predicate returns true. | ||
471 | * We use a bisection algorithm (half interval) to make the algorithm scalable. | ||
472 | * The worst-case complexity is about O(log(n)^2) in practice. | ||
473 | * Only for extremely small values (under 10) do you notice it taking more iterations than a linear search. | ||
474 | * The number of predicate invocations only hits a few hundred when the maximized value | ||
475 | * is in the tens of millions, so prepare for the predicate to be invoked between 10 and 100 times. | ||
476 | * And of course it is fantastic with powers of 2, which are densely packed in values under 100 anyway. | ||
477 | * The Predicate<long> parameter must be a function that accepts a long and returns a bool. | ||
478 | * */ | ||
479 | public long HalfIntervalMaximize(long start, Predicate<long> pred) | ||
480 | { | ||
481 | long prev = start, curr = start, biggest = 0; | ||
482 | |||
483 | if(start < 0) | ||
484 | throw new IndexOutOfRangeException("Start value for HalfIntervalMaximize must be non-negative"); | ||
485 | |||
486 | do | ||
487 | { | ||
488 | if(pred(curr)) | ||
489 | { | ||
490 | if(curr > biggest) | ||
491 | { | ||
492 | biggest = curr; | ||
493 | } | ||
494 | prev = curr; | ||
495 | if(curr == 0) | ||
496 | { | ||
497 | //Special case because 0 * 2 = 0 :) | ||
498 | curr = 1; | ||
499 | } | ||
500 | else | ||
501 | { | ||
502 | //Look deeper | ||
503 | curr *= 2; | ||
504 | } | ||
505 | } | ||
506 | else | ||
507 | { | ||
508 | // We went too far, back off halfway | ||
509 | curr = (curr + prev) / 2; | ||
510 | } | ||
511 | } | ||
512 | while(curr - prev > 0); | ||
513 | |||
514 | return biggest; | ||
515 | } | ||
516 | |||
517 | public bool FileExistsTest(long num) | ||
518 | { | ||
519 | FileInfo test = new FileInfo(m_dirName + Path.DirectorySeparatorChar + m_regionName + "_" + num + ".oar"); | ||
520 | return test.Exists; | ||
521 | } | ||
522 | |||
523 | |||
524 | //Very slow, hence why we try the HalfIntervalMaximize first! | ||
525 | public FileInfo UniqueFileSearchLinear(long start) | ||
526 | { | ||
527 | long l = start; | ||
528 | FileInfo retval = null; | ||
529 | do | ||
530 | { | ||
531 | retval = new FileInfo(m_dirName + Path.DirectorySeparatorChar + m_regionName + "_" + (l++) + ".oar"); | ||
532 | } | ||
533 | while(retval.Exists); | ||
534 | |||
535 | return retval; | ||
536 | } | ||
537 | } | ||
538 | |||
539 | } | ||
540 | |||