OpenConcerto

Dépôt officiel du code source de l'ERP OpenConcerto
sonarqube

svn://code.openconcerto.org/openconcerto

Rev

Rev 83 | Rev 142 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
17 ilm 1
/*
2
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
3
 *
4
 * Copyright 2011 OpenConcerto, by ILM Informatique. All rights reserved.
5
 *
6
 * The contents of this file are subject to the terms of the GNU General Public License Version 3
7
 * only ("GPL"). You may not use this file except in compliance with the License. You can obtain a
8
 * copy of the License at http://www.gnu.org/licenses/gpl-3.0.html See the License for the specific
9
 * language governing permissions and limitations under the License.
10
 *
11
 * When distributing the software, include this License Header Notice in each file.
12
 */
13
 
14
 package org.openconcerto.sql.view.list;
15
 
93 ilm 16
import org.openconcerto.sql.Log;
73 ilm 17
import org.openconcerto.sql.model.SQLRow;
18
import org.openconcerto.sql.model.SQLRowValues;
93 ilm 19
import org.openconcerto.sql.model.SQLRowValues.CreateMode;
73 ilm 20
import org.openconcerto.sql.model.SQLRowValuesCluster.State;
17 ilm 21
import org.openconcerto.sql.model.SQLTable;
22
import org.openconcerto.sql.model.SQLTableEvent;
83 ilm 23
import org.openconcerto.sql.model.SQLTableModifiedListener;
80 ilm 24
import org.openconcerto.sql.model.graph.Link.Direction;
93 ilm 25
import org.openconcerto.sql.model.graph.Path;
17 ilm 26
import org.openconcerto.sql.view.list.UpdateRunnable.RmAllRunnable;
93 ilm 27
import org.openconcerto.sql.view.list.search.SearchOne;
28
import org.openconcerto.sql.view.list.search.SearchOne.Mode;
29
import org.openconcerto.sql.view.list.search.SearchQueue;
30
import org.openconcerto.sql.view.list.search.SearchQueue.SetStateRunnable;
31
import org.openconcerto.utils.ListMap;
73 ilm 32
import org.openconcerto.utils.RecursionType;
17 ilm 33
import org.openconcerto.utils.SleepingQueue;
93 ilm 34
import org.openconcerto.utils.Tuple2;
17 ilm 35
import org.openconcerto.utils.cc.IClosure;
93 ilm 36
import org.openconcerto.utils.cc.IPredicate;
73 ilm 37
import org.openconcerto.utils.cc.ITransformer;
17 ilm 38
 
39
import java.beans.PropertyChangeEvent;
40
import java.beans.PropertyChangeListener;
93 ilm 41
import java.util.ArrayList;
42
import java.util.Collections;
61 ilm 43
import java.util.Deque;
73 ilm 44
import java.util.HashSet;
93 ilm 45
import java.util.Iterator;
46
import java.util.List;
73 ilm 47
import java.util.Set;
17 ilm 48
import java.util.concurrent.FutureTask;
93 ilm 49
import java.util.logging.Level;
17 ilm 50
 
93 ilm 51
import net.jcip.annotations.GuardedBy;
52
 
17 ilm 53
import org.apache.commons.collections.CollectionUtils;
54
 
93 ilm 55
public final class UpdateQueue extends SleepingQueue {
17 ilm 56
 
57
    /**
58
     * Whether the passed future performs an update.
59
     *
60
     * @param f a task in this queue, can be <code>null</code>.
61
     * @return <code>true</code> if <code>f</code> loads from the db.
62
     */
63
    static boolean isUpdate(FutureTask<?> f) {
93 ilm 64
        return isUpdate(SearchQueue.getRunnable(f));
17 ilm 65
    }
66
 
93 ilm 67
    static boolean isUpdate(Runnable r) {
68
        return r instanceof UpdateRunnable;
69
    }
70
 
71
    private static boolean isCancelableUpdate(Runnable r) {
17 ilm 72
        // don't cancel RmAll so we can put an UpdateAll right after it (the UpdateAll won't be
73
        // executed since RmAll put the queue to sleep)
93 ilm 74
        return isUpdate(r) && !(r instanceof RmAllRunnable);
17 ilm 75
    }
76
 
77
    private final class TableListener implements SQLTableModifiedListener, PropertyChangeListener {
93 ilm 78
        @Override
17 ilm 79
        public void tableModified(SQLTableEvent evt) {
80
            if (UpdateQueue.this.alwaysUpdateAll)
81
                putUpdateAll();
93 ilm 82
            else if (evt.getMode() == SQLTableEvent.Mode.ROW_UPDATED) {
17 ilm 83
                rowModified(evt);
73 ilm 84
            } else {
85
                rowAddedOrDeleted(evt);
17 ilm 86
            }
87
        }
88
 
89
        @Override
90
        public void propertyChange(PropertyChangeEvent evt) {
91
            // where changed
93 ilm 92
            stateChanged(null, getModel().getReq().createState());
17 ilm 93
        }
94
    }
95
 
96
    private final ITableModel tableModel;
93 ilm 97
    // thread-confined
98
    private SQLTableModelSourceState state;
99
    @GuardedBy("itself")
100
    private final List<ListSQLLine> fullList;
101
    @GuardedBy("fullList")
102
    private SQLTableModelColumns columns;
17 ilm 103
    private final TableListener tableListener;
73 ilm 104
    // TODO rm : needed for now since our optimizations are false if there's a where not on the
105
    // primary table, see http://192.168.1.10:3000/issues/show/22
17 ilm 106
    private boolean alwaysUpdateAll = false;
93 ilm 107
    private final IClosure<Deque<FutureTask<?>>> cancelClosure;
17 ilm 108
 
109
    public UpdateQueue(ITableModel model) {
110
        super(UpdateQueue.class.getSimpleName() + " on " + model);
111
        this.tableModel = model;
93 ilm 112
        this.fullList = new ArrayList<ListSQLLine>();
113
        this.cancelClosure = createCancelClosure(this, new ITransformer<FutureTask<?>, TaskType>() {
114
            @Override
115
            public TaskType transformChecked(FutureTask<?> input) {
116
                final Runnable r = SearchQueue.getRunnable(input);
117
                if (isCancelableUpdate(r))
118
                    return TaskType.COMPUTE;
119
                else if (r instanceof SetStateRunnable)
120
                    return TaskType.SET_STATE;
121
                else
122
                    return TaskType.USER;
123
            }
124
        });
17 ilm 125
        this.tableListener = new TableListener();
93 ilm 126
    }
127
 
128
    private final ITableModel getModel() {
129
        return this.tableModel;
130
    }
131
 
132
    @Override
133
    protected void started() {
17 ilm 134
        // savoir quand les tables qu'on affiche changent
93 ilm 135
        addListeners();
136
        stateChanged(null, this.getModel().getReq().createState());
137
        // Only starts once there's something to search, that way the runnable passed to
138
        // ITableModel.search() will be meaningful
139
        // SetStateRunnable since this must not be cancelled by an updateAll, but it shouldn't
140
        // prevent earlier updates to be cancelled
141
        this.put(new SetStateRunnable() {
142
            @Override
143
            public void run() {
144
                getModel().getSearchQueue().start();
145
            }
146
        });
17 ilm 147
    }
148
 
93 ilm 149
    @Override
150
    protected void dying() throws Exception {
151
        super.dying();
152
        assert currentlyInQueue();
153
 
154
        // only kill searchQ once updateQ is really dead, otherwise the currently executing
155
        // update might finish once the searchQ is already dead.
156
 
157
        final SearchQueue searchQueue = getModel().getSearchQueue();
158
        // state cannot change since we only change it in this thread (except for an Error being
159
        // thrown and killing the queue)
160
        final RunningState state = searchQueue.getRunningState();
161
        // not started or there was an Error
162
        if (state == RunningState.NEW || state == RunningState.DEAD)
163
            return;
164
        if (state == RunningState.WILL_DIE || state == RunningState.DYING)
165
            throw new IllegalStateException("Someone else already called die()");
166
        try {
167
            searchQueue.die().get();
168
        } catch (Exception e) {
169
            if (searchQueue.getRunningState() != RunningState.DEAD)
170
                throw e;
171
            // there was an Error in the last run task or while in die(), but it's OK we wanted the
172
            // queue dead
173
            Log.get().log(Level.CONFIG, "Exception while killing search queue", e);
174
        }
175
        assert searchQueue.getRunningState().compareTo(RunningState.DYING) >= 0;
176
        searchQueue.join();
177
        assert searchQueue.getRunningState() == RunningState.DEAD;
178
    }
179
 
180
    final List<ListSQLLine> getFullList() {
181
        return this.fullList;
182
    }
183
 
184
    public final Tuple2<List<ListSQLLine>, SQLTableModelColumns> copyFullList() {
185
        final Tuple2<List<ListSQLLine>, SQLTableModelColumns> res;
186
        final List<ListSQLLine> fullList = this.getFullList();
187
        synchronized (fullList) {
188
            res = Tuple2.<List<ListSQLLine>, SQLTableModelColumns> create(new ArrayList<ListSQLLine>(fullList), this.columns);
189
        }
190
        return res;
191
    }
192
 
193
    public final ListSQLLine getLine(final Number id) {
194
        final ListSQLLine res;
195
        final List<ListSQLLine> fullList = this.getFullList();
196
        synchronized (fullList) {
197
            res = ListSQLLine.fromID(fullList, id.intValue());
198
        }
199
        return res;
200
    }
201
 
202
    /**
203
     * The lines and their path affected by a change of the passed row.
204
     *
205
     * @param r the row that has changed.
206
     * @return the refreshed lines and their changed paths.
207
     */
208
    protected final ListMap<ListSQLLine, Path> getAffectedLines(final SQLRow r) {
209
        return this.getAffected(r, new ListMap<ListSQLLine, Path>(), true);
210
    }
211
 
212
    protected final ListMap<Path, ListSQLLine> getAffectedPaths(final SQLRow r) {
213
        return this.getAffected(r, new ListMap<Path, ListSQLLine>(), false);
214
    }
215
 
216
    // must be called from within this queue, as this method use fullList
217
    private <K, V> ListMap<K, V> getAffected(final SQLRow r, final ListMap<K, V> res, final boolean byLine) {
218
        final List<ListSQLLine> fullList = this.getFullList();
219
        synchronized (fullList) {
220
            final SQLTable t = r.getTable();
221
            final int id = r.getID();
222
            if (id < SQLRow.MIN_VALID_ID)
223
                throw new IllegalArgumentException("invalid ID: " + id);
224
            if (!fullList.isEmpty()) {
225
                final SQLRowValues proto = this.getModel().getLinesSource().getParent().getMaxGraph();
226
                final List<Path> pathsToT = new ArrayList<Path>();
227
                proto.getGraph().walk(proto, pathsToT, new ITransformer<State<List<Path>>, List<Path>>() {
228
                    @Override
229
                    public List<Path> transformChecked(State<List<Path>> input) {
230
                        if (input.getCurrent().getTable() == t) {
231
                            input.getAcc().add(input.getPath());
232
                        }
233
                        return input.getAcc();
234
                    }
235
                }, RecursionType.BREADTH_FIRST, Direction.ANY);
236
                for (final Path p : pathsToT) {
237
                    final String lastReferentField = SearchQueue.getLastReferentField(p);
238
                    for (final ListSQLLine line : fullList) {
239
                        boolean put = false;
240
                        for (final SQLRowValues current : line.getRow().followPath(p, CreateMode.CREATE_NONE, false)) {
241
                            // works for rowValues w/o any ID
242
                            if (current != null && current.getID() == id) {
243
                                put = true;
244
                            }
245
                        }
246
                        // if the modified row isn't in the existing line, it might still affect it
247
                        // if it's a referent row insertion
248
                        if (!put && lastReferentField != null && r.exists()) {
249
                            final int foreignID = r.getInt(lastReferentField);
250
                            for (final SQLRowValues current : line.getRow().followPath(p.minusLast(), CreateMode.CREATE_NONE, false)) {
251
                                if (current.getID() == foreignID) {
252
                                    put = true;
253
                                }
254
                            }
255
                        }
256
                        if (put) {
257
                            // add to the list of paths that have been refreshed
258
                            add(byLine, res, p, line);
259
                        }
260
                    }
261
                }
262
            }
263
        }
264
        return res;
265
    }
266
 
267
    @SuppressWarnings("unchecked")
268
    <V, K> void add(boolean byLine, ListMap<K, V> res, final Path p, final ListSQLLine line) {
269
        if (byLine)
270
            res.add((K) line, (V) p);
271
        else
272
            res.add((K) p, (V) line);
273
    }
274
 
275
    final void setFullList(final List<ListSQLLine> tmp, final SQLTableModelColumns cols) {
276
        final List<ListSQLLine> fullList = this.getFullList();
277
        synchronized (fullList) {
278
            fullList.clear();
279
            fullList.addAll(tmp);
280
            // MAYBE only sort() if it can't be done by the SELECT
281
            // but comparing ints (field ORDRE) is quite fast : 170ms for 100,000 items
282
            Collections.sort(fullList);
283
            if (cols != null)
284
                this.columns = cols;
285
        }
286
        this.tableModel.getSearchQueue().fullListChanged();
287
    }
288
 
289
    final void reorder(final List<Number> idsOrder) {
290
        final List<ListSQLLine> fullList = this.getFullList();
291
        synchronized (fullList) {
292
            for (final ListSQLLine l : fullList) {
293
                final Number newOrder;
294
                if (idsOrder == null) {
295
                    newOrder = null;
296
                } else {
297
                    final int index = idsOrder.indexOf(l.getID());
298
                    if (index < 0)
299
                        throw new IllegalArgumentException("Missing id " + l.getID() + " in " + idsOrder);
300
                    newOrder = index;
301
                }
302
                l.setOrder(newOrder);
303
            }
304
            Collections.sort(fullList);
305
        }
306
        this.tableModel.getSearchQueue().orderChanged();
307
    }
308
 
309
    // vals can be null if we're removing a referent row
310
    final void updateLine(ListSQLLine line, Path p, int valsID, SQLRowValues vals) {
311
        final Set<Integer> modifiedCols = line.loadAt(valsID, vals, p);
312
        this.tableModel.getSearchQueue().changeFullList(line.getID(), line, modifiedCols, SearchOne.Mode.CHANGE);
313
    }
314
 
315
    final ListSQLLine replaceLine(final int id, final ListSQLLine newLine) {
316
        final Mode mode;
317
        final List<ListSQLLine> fullList = this.getFullList();
318
        final ListSQLLine oldLine;
319
        synchronized (fullList) {
320
            final int modifiedIndex = ListSQLLine.indexFromID(fullList, id);
321
            oldLine = modifiedIndex < 0 ? null : fullList.get(modifiedIndex);
322
 
323
            if (modifiedIndex < 0) {
324
                // la ligne n'était dans notre liste
325
                if (newLine != null) {
326
                    // mais elle existe : ajout
327
                    // ATTN on ajoute à la fin, sans se soucier de l'ordre
328
                    fullList.add(newLine);
329
                    Collections.sort(fullList);
330
                    mode = Mode.ADD;
331
                } else {
332
                    // et elle n'y est toujours pas
333
                    mode = Mode.NO_CHANGE;
334
                }
335
            } else {
336
                // la ligne était dans notre liste
337
                if (newLine != null) {
338
                    // mettre à jour
339
                    fullList.set(modifiedIndex, newLine);
340
                    Collections.sort(fullList);
341
                    mode = Mode.CHANGE;
342
                } else {
343
                    // elle est effacée ou filtrée
344
                    fullList.remove(modifiedIndex);
345
                    mode = Mode.REMOVE;
346
                }
347
            }
348
        }
349
 
350
        // notify search queue
351
        this.tableModel.getSearchQueue().changeFullList(id, newLine, null, mode);
352
        return oldLine;
353
    }
354
 
355
    public final int getFullListSize() {
356
        final List<ListSQLLine> fullList = this.getFullList();
357
        synchronized (fullList) {
358
            return fullList.size();
359
        }
360
    }
361
 
17 ilm 362
    void setAlwaysUpdateAll(boolean b) {
363
        this.alwaysUpdateAll = b;
364
    }
365
 
366
    // *** listeners
367
 
93 ilm 368
    void stateChanged(final SQLTableModelSourceState beforeState, final SQLTableModelSourceState afterState) {
369
        if (afterState == null)
370
            throw new NullPointerException("Null state");
371
 
372
        // As in SearchQueue :
373
        // needs to be 2 different runnables, that way if the source is changed and then the table
374
        // is updated : the queue would naively contain setState, updateAll, updateAll and thus we
375
        // can cancel one updateAll. Whereas if the setState was contained in updateAll, we
376
        // couldn't cancel it.
377
        // use tasksDo() so that no other runnable can come between setState and updateAll.
378
        // Otherwise an updateOne might use new columns and add a line with different columns than
379
        // the full list.
380
        this.tasksDo(new IClosure<Deque<FutureTask<?>>>() {
381
            @Override
382
            public void executeChecked(Deque<FutureTask<?>> input) {
383
                put(new SetStateRunnable() {
384
                    @Override
385
                    public void run() {
386
                        UpdateQueue.this.state = afterState;
387
                    }
388
                });
389
                // TODO if request didn't change and the new graph is smaller, copy and prune the
390
                // rows
391
                putUpdateAll();
392
            }
393
        });
394
    }
395
 
396
    protected final SQLTableModelSourceState getState() {
397
        assert this.currentlyInQueue();
398
        if (this.state == null)
399
            throw new IllegalStateException("Not yet started");
400
        return this.state;
401
    }
402
 
17 ilm 403
    @Override
83 ilm 404
    protected void willDie() {
93 ilm 405
        this.removeListeners();
83 ilm 406
        super.willDie();
17 ilm 407
    }
408
 
93 ilm 409
    protected final void addTableListener() {
17 ilm 410
        for (final SQLTable t : this.tableModel.getReq().getTables()) {
411
            t.addTableModifiedListener(this.tableListener);
412
        }
93 ilm 413
    }
414
 
415
    private void addListeners() {
416
        this.addTableListener();
17 ilm 417
        this.tableModel.getLinesSource().addListener(this.tableListener);
418
    }
419
 
93 ilm 420
    protected final void rmTableListener() {
17 ilm 421
        for (final SQLTable t : this.tableModel.getReq().getTables()) {
422
            t.removeTableModifiedListener(this.tableListener);
423
        }
93 ilm 424
    }
425
 
426
    private void removeListeners() {
427
        this.rmTableListener();
17 ilm 428
        this.tableModel.getLinesSource().rmListener(this.tableListener);
429
    }
430
 
431
    // *** une des tables que l'on affiche a changé
432
 
433
    void rowModified(final SQLTableEvent evt) {
434
        final int id = evt.getId();
73 ilm 435
        if (id < SQLRow.MIN_VALID_ID) {
17 ilm 436
            this.putUpdateAll();
437
        } else if (CollectionUtils.containsAny(this.tableModel.getReq().getLineFields(), evt.getFields())) {
438
            this.put(evt);
439
        }
440
        // si on n'affiche pas le champ ignorer
441
    }
442
 
73 ilm 443
    // takes 1-2ms, perhaps cache
444
    final Set<SQLTable> getNotForeignTables() {
445
        final Set<SQLTable> res = new HashSet<SQLTable>();
446
        final SQLRowValues maxGraph = this.tableModel.getReq().getMaxGraph();
447
        maxGraph.getGraph().walk(maxGraph, res, new ITransformer<State<Set<SQLTable>>, Set<SQLTable>>() {
448
            @Override
449
            public Set<SQLTable> transformChecked(State<Set<SQLTable>> input) {
450
                if (input.getPath().length() == 0 || input.isBackwards())
451
                    input.getAcc().add(input.getCurrent().getTable());
452
                return input.getAcc();
453
            }
80 ilm 454
        }, RecursionType.BREADTH_FIRST, Direction.ANY);
73 ilm 455
        return res;
17 ilm 456
    }
457
 
73 ilm 458
    void rowAddedOrDeleted(final SQLTableEvent evt) {
459
        if (evt.getId() < SQLRow.MIN_VALID_ID)
460
            this.putUpdateAll();
461
        // if a row of a table that we point to is added, we will care when the referent table will
462
        // point to it
463
        else if (this.getNotForeignTables().contains(evt.getTable()))
464
            this.put(evt);
17 ilm 465
    }
466
 
467
    // *** puts
468
 
93 ilm 469
    public final void putExternalUpdated(final String externalID, final IPredicate<ListSQLLine> affectedPredicate) {
470
        this.put(new Runnable() {
471
            @Override
472
            public void run() {
473
                externalUpdated(externalID, affectedPredicate);
474
            }
475
        });
476
    }
477
 
478
    protected final void externalUpdated(final String externalID, final IPredicate<ListSQLLine> affectedPredicate) {
479
        final List<ListSQLLine> fullList = this.getFullList();
480
        synchronized (fullList) {
481
            final Set<Integer> indexes = new HashSet<Integer>();
482
            int i = 0;
483
            for (final SQLTableModelColumn col : this.columns.getAllColumns()) {
484
                if (col.getUsedExternals().contains(externalID)) {
485
                    indexes.add(i);
486
                }
487
                i++;
488
            }
489
            if (indexes.isEmpty()) {
490
                Log.get().log(Level.INFO, "No columns use " + externalID + " in " + this);
491
                return;
492
            }
493
 
494
            for (final ListSQLLine line : fullList) {
495
                if (affectedPredicate.evaluateChecked(line)) {
496
                    this.tableModel.getSearchQueue().changeFullList(line.getID(), line, indexes, SearchOne.Mode.CHANGE);
497
                }
498
            }
499
        }
500
    }
501
 
17 ilm 502
    private void put(SQLTableEvent evt) {
503
        this.put(UpdateRunnable.create(this.tableModel, evt));
504
    }
505
 
506
    public void putUpdateAll() {
507
        this.put(UpdateRunnable.create(this.tableModel));
508
    }
509
 
510
    /**
511
     * If this is sleeping, empty the list and call {@link #putUpdateAll()} so that the list reload
512
     * itself when this wakes up.
513
     *
514
     * @throws IllegalStateException if not sleeping.
515
     */
516
    void putRemoveAll() {
517
        if (!this.isSleeping())
518
            throw new IllegalStateException("not sleeping");
519
        // no user runnables can come between the RmAll and the UpdateAll since runnableAdded()
520
        // is blocked by our lock, so there won't be any incoherence for them
521
        this.put(UpdateRunnable.createRmAll(this, this.tableModel));
522
        this.setSleeping(false);
523
        // reload the empty list when waking up
524
        this.putUpdateAll();
525
    }
526
 
93 ilm 527
    @Override
528
    protected void willPut(final FutureTask<?> qr) throws InterruptedException {
529
        if (SearchQueue.getRunnable(qr) instanceof ChangeAllRunnable) {
17 ilm 530
            // si on met tout à jour, ne sert à rien de garder les maj précédentes.
93 ilm 531
            this.tasksDo(this.cancelClosure);
532
        }
533
    }
534
 
535
    static public enum TaskType {
536
        USER(false, true), COMPUTE(true, false), SET_STATE(false, false);
537
 
538
        private final boolean cancelable, dependsOnPrevious;
539
 
540
        private TaskType(boolean cancelable, boolean dependsOnPrevious) {
541
            this.cancelable = cancelable;
542
            this.dependsOnPrevious = dependsOnPrevious;
543
        }
544
    }
545
 
546
    static public final IClosure<Deque<FutureTask<?>>> createCancelClosure(final SleepingQueue q, final ITransformer<? super FutureTask<?>, TaskType> cancelablePred) {
547
        return new IClosure<Deque<FutureTask<?>>>() {
548
            @Override
549
            public void executeChecked(final Deque<FutureTask<?>> tasks) {
550
                // on part de la fin et on supprime toutes les maj jusqu'a ce qu'on trouve
551
                // un runnable qui n'est pas annulable
552
                final Iterator<FutureTask<?>> iter = tasks.descendingIterator();
553
                boolean needsPrevious = false;
554
                while (iter.hasNext() && !needsPrevious) {
555
                    final FutureTask<?> current = iter.next();
556
                    final TaskType type = cancelablePred.transformChecked(current);
557
                    needsPrevious = type.dependsOnPrevious;
558
                    if (type.cancelable)
559
                        iter.remove();
560
                }
561
                // if we stop only because we ran out of items, continue with beingRun
562
                if (!needsPrevious) {
563
                    // before trying to cancel being run we should have been through all the backlog
564
                    assert !iter.hasNext();
565
                    final FutureTask<?> br = q.getBeingRun();
566
                    if (br != null && cancelablePred.transformChecked(br).cancelable) {
567
                        // might already be done by now, but it's OK cancel() will just return false
568
                        br.cancel(true);
17 ilm 569
                    }
570
                }
93 ilm 571
            }
572
        };
17 ilm 573
    }
574
 
575
}