Add label templates [#22], also Bowie is the *goblin* king... oops!
This commit is contained in:
parent
af2e786be8
commit
16095cf78a
33
README.md
33
README.md
|
@ -17,16 +17,16 @@ other projects.
|
||||||
```
|
```
|
||||||
title Labyrinth
|
title Labyrinth
|
||||||
|
|
||||||
Bowie -> Gremlin: You remind me of the babe
|
Bowie -> Goblin: You remind me of the babe
|
||||||
Gremlin -> Bowie: What babe?
|
Goblin -> Bowie: What babe?
|
||||||
Bowie -> Gremlin: The babe with the power
|
Bowie -> Goblin: The babe with the power
|
||||||
Gremlin -> Bowie: What power?
|
Goblin -> Bowie: What power?
|
||||||
note right of Bowie, Gremlin: Most people get muddled here!
|
note right of Bowie, Goblin: Most people get muddled here!
|
||||||
Bowie -> Gremlin: 'The power of voodoo'
|
Bowie -> Goblin: 'The power of voodoo'
|
||||||
Gremlin -> Bowie: "Who-do?"
|
Goblin -> Bowie: "Who-do?"
|
||||||
Bowie -> Gremlin: You do!
|
Bowie -> Goblin: You do!
|
||||||
Gremlin -> Bowie: Do what?
|
Goblin -> Bowie: Do what?
|
||||||
Bowie -> Gremlin: Remind me of the babe!
|
Bowie -> Goblin: Remind me of the babe!
|
||||||
|
|
||||||
Bowie -> Audience: Sings
|
Bowie -> Audience: Sings
|
||||||
|
|
||||||
|
@ -113,6 +113,19 @@ else
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Label Templates
|
||||||
|
|
||||||
|
<img src="screenshots/LabelTemplates.png" alt="Label Templates preview" width="200" align="right" />
|
||||||
|
|
||||||
|
```
|
||||||
|
autolabel "[<inc>] <label>"
|
||||||
|
|
||||||
|
begin "Underpants\nGnomes" as A
|
||||||
|
A <- ]: Collect underpants
|
||||||
|
A <-> ]: ???
|
||||||
|
A <- ]: Profit!
|
||||||
|
```
|
||||||
|
|
||||||
### Multiline Text
|
### Multiline Text
|
||||||
|
|
||||||
<img src="screenshots/MultilineText.png" alt="Multiline Text preview" width="200" align="right" />
|
<img src="screenshots/MultilineText.png" alt="Multiline Text preview" width="200" align="right" />
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
@ -25,16 +25,16 @@
|
||||||
const defaultCode = (
|
const defaultCode = (
|
||||||
'title Labyrinth\n' +
|
'title Labyrinth\n' +
|
||||||
'\n' +
|
'\n' +
|
||||||
'Bowie -> Gremlin: You remind me of the babe\n' +
|
'Bowie -> Goblin: You remind me of the babe\n' +
|
||||||
'Gremlin -> Bowie: What babe?\n' +
|
'Goblin -> Bowie: What babe?\n' +
|
||||||
'Bowie -> Gremlin: The babe with the power\n' +
|
'Bowie -> Goblin: The babe with the power\n' +
|
||||||
'Gremlin -> Bowie: What power?\n' +
|
'Goblin -> Bowie: What power?\n' +
|
||||||
'note right of Bowie, Gremlin: Most people get muddled here!\n' +
|
'note right of Bowie, Goblin: Most people get muddled here!\n' +
|
||||||
'Bowie -> Gremlin: \'The power of voodoo\'\n' +
|
'Bowie -> Goblin: \'The power of voodoo\'\n' +
|
||||||
'Gremlin -> Bowie: "Who-do?"\n' +
|
'Goblin -> Bowie: "Who-do?"\n' +
|
||||||
'Bowie -> Gremlin: You do!\n' +
|
'Bowie -> Goblin: You do!\n' +
|
||||||
'Gremlin -> Bowie: Do what?\n' +
|
'Goblin -> Bowie: Do what?\n' +
|
||||||
'Bowie -> Gremlin: Remind me of the babe!\n' +
|
'Bowie -> Goblin: Remind me of the babe!\n' +
|
||||||
'\n' +
|
'\n' +
|
||||||
'Bowie -> Audience: Sings\n' +
|
'Bowie -> Audience: Sings\n' +
|
||||||
'\n' +
|
'\n' +
|
||||||
|
@ -86,6 +86,16 @@
|
||||||
'end B'
|
'end B'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Numbered labels',
|
||||||
|
code: 'autolabel "[<inc>] <label>"',
|
||||||
|
preview: (
|
||||||
|
'autolabel "[<inc>] <label>"\n' +
|
||||||
|
'A -> B: Foo\n' +
|
||||||
|
'A <- B: Bar\n' +
|
||||||
|
'A -> B: Baz'
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Conditional blocks',
|
title: 'Conditional blocks',
|
||||||
code: (
|
code: (
|
||||||
|
|
|
@ -207,6 +207,10 @@ define(['core/ArrayUtilities'], (array) => {
|
||||||
'left': makeSideNote('left'),
|
'left': makeSideNote('left'),
|
||||||
'right': makeSideNote('right'),
|
'right': makeSideNote('right'),
|
||||||
}},
|
}},
|
||||||
|
'autolabel': {type: 'keyword', suggest: true, then: {
|
||||||
|
'off': {type: 'keyword', suggest: true, then: {}},
|
||||||
|
'': textToEnd,
|
||||||
|
}},
|
||||||
'simultaneously': {type: 'keyword', suggest: true, then: {
|
'simultaneously': {type: 'keyword', suggest: true, then: {
|
||||||
':': {type: 'operator', suggest: true, then: {}},
|
':': {type: 'operator', suggest: true, then: {}},
|
||||||
'with': {type: 'keyword', suggest: true, then: {
|
'with': {type: 'keyword', suggest: true, then: {
|
||||||
|
|
|
@ -179,6 +179,7 @@ define(['core/ArrayUtilities'], (array) => {
|
||||||
this.agentStates = new Map();
|
this.agentStates = new Map();
|
||||||
this.agentAliases = new Map();
|
this.agentAliases = new Map();
|
||||||
this.agents = [];
|
this.agents = [];
|
||||||
|
this.labelPattern = null;
|
||||||
this.blockCount = 0;
|
this.blockCount = 0;
|
||||||
this.nesting = [];
|
this.nesting = [];
|
||||||
this.markers = new Set();
|
this.markers = new Set();
|
||||||
|
@ -191,6 +192,7 @@ define(['core/ArrayUtilities'], (array) => {
|
||||||
'agent define': this.handleAgentDefine.bind(this),
|
'agent define': this.handleAgentDefine.bind(this),
|
||||||
'agent begin': this.handleAgentBegin.bind(this),
|
'agent begin': this.handleAgentBegin.bind(this),
|
||||||
'agent end': this.handleAgentEnd.bind(this),
|
'agent end': this.handleAgentEnd.bind(this),
|
||||||
|
'label pattern': this.handleLabelPattern.bind(this),
|
||||||
'connect': this.handleConnect.bind(this),
|
'connect': this.handleConnect.bind(this),
|
||||||
'note over': this.handleNote.bind(this),
|
'note over': this.handleNote.bind(this),
|
||||||
'note left': this.handleNote.bind(this),
|
'note left': this.handleNote.bind(this),
|
||||||
|
@ -373,6 +375,36 @@ define(['core/ArrayUtilities'], (array) => {
|
||||||
this.addStage({type: 'async', target}, false);
|
this.addStage({type: 'async', target}, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleLabelPattern({pattern}) {
|
||||||
|
this.labelPattern = pattern.slice();
|
||||||
|
for(let i = 0; i < this.labelPattern.length; ++ i) {
|
||||||
|
const part = this.labelPattern[i];
|
||||||
|
if(typeof part === 'object' && part.start !== undefined) {
|
||||||
|
this.labelPattern[i] = Object.assign({
|
||||||
|
current: part.start,
|
||||||
|
}, part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLabelPattern(label) {
|
||||||
|
let result = '';
|
||||||
|
const tokens = {
|
||||||
|
'label': label,
|
||||||
|
};
|
||||||
|
this.labelPattern.forEach((part) => {
|
||||||
|
if(typeof part === 'string') {
|
||||||
|
result += part;
|
||||||
|
} else if(part.token !== undefined) {
|
||||||
|
result += tokens[part.token];
|
||||||
|
} else if(part.current !== undefined) {
|
||||||
|
result += part.current.toFixed(part.dp);
|
||||||
|
part.current += part.inc;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
handleConnect({agents, label, options}) {
|
handleConnect({agents, label, options}) {
|
||||||
const beginAgents = (agents
|
const beginAgents = (agents
|
||||||
.filter(agentHasFlag('begin'))
|
.filter(agentHasFlag('begin'))
|
||||||
|
@ -412,7 +444,7 @@ define(['core/ArrayUtilities'], (array) => {
|
||||||
const connectStage = {
|
const connectStage = {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
agentNames,
|
agentNames,
|
||||||
label,
|
label: this.applyLabelPattern(label),
|
||||||
options,
|
options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -528,6 +560,7 @@ define(['core/ArrayUtilities'], (array) => {
|
||||||
this.agents.length = 0;
|
this.agents.length = 0;
|
||||||
this.blockCount = 0;
|
this.blockCount = 0;
|
||||||
this.nesting.length = 0;
|
this.nesting.length = 0;
|
||||||
|
this.labelPattern = [{token: 'label'}];
|
||||||
const globals = this.beginNested('global', '', '', 0);
|
const globals = this.beginNested('global', '', '', 0);
|
||||||
|
|
||||||
stages.forEach(this.handleStage);
|
stages.forEach(this.handleStage);
|
||||||
|
|
|
@ -26,6 +26,10 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
|
||||||
return {type: 'block end'};
|
return {type: 'block end'};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
labelPattern: (pattern, {ln = 0} = {}) => {
|
||||||
|
return {type: 'label pattern', pattern, ln};
|
||||||
|
},
|
||||||
|
|
||||||
defineAgents: (agentNames, {ln = 0} = {}) => {
|
defineAgents: (agentNames, {ln = 0} = {}) => {
|
||||||
return {
|
return {
|
||||||
type: 'agent define',
|
type: 'agent define',
|
||||||
|
@ -325,6 +329,56 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses label patterns for connections', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
PARSED.labelPattern(['foo ', {token: 'label'}, ' bar']),
|
||||||
|
PARSED.connect(['A', 'B'], {label: 'myLabel'}),
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
jasmine.anything(),
|
||||||
|
GENERATED.connect(['A', 'B'], {
|
||||||
|
label: 'foo myLabel bar',
|
||||||
|
}),
|
||||||
|
jasmine.anything(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies counters in label patterns', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
PARSED.labelPattern([{start: 3, inc: 2, dp: 0}, ' suffix']),
|
||||||
|
PARSED.connect(['A', 'B'], {label: 'foo'}),
|
||||||
|
PARSED.connect(['A', 'B'], {label: 'bar'}),
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
jasmine.anything(),
|
||||||
|
GENERATED.connect(['A', 'B'], {
|
||||||
|
label: '3 suffix',
|
||||||
|
}),
|
||||||
|
GENERATED.connect(['A', 'B'], {
|
||||||
|
label: '5 suffix',
|
||||||
|
}),
|
||||||
|
jasmine.anything(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies counter rounding in label patterns', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
PARSED.labelPattern([{start: 0.52, inc: 1, dp: 1}, ' suffix']),
|
||||||
|
PARSED.connect(['A', 'B'], {label: 'foo'}),
|
||||||
|
PARSED.connect(['A', 'B'], {label: 'bar'}),
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
jasmine.anything(),
|
||||||
|
GENERATED.connect(['A', 'B'], {
|
||||||
|
label: '0.5 suffix',
|
||||||
|
}),
|
||||||
|
GENERATED.connect(['A', 'B'], {
|
||||||
|
label: '1.5 suffix',
|
||||||
|
}),
|
||||||
|
jasmine.anything(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates implicit end stages for all remaining agents', () => {
|
it('creates implicit end stages for all remaining agents', () => {
|
||||||
const sequence = generator.generate({
|
const sequence = generator.generate({
|
||||||
meta: {
|
meta: {
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
define(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const LABEL_PATTERN = /(.*?)<([^<>]*)>/g;
|
||||||
|
const DP_PATTERN = /\.([0-9]*)/;
|
||||||
|
|
||||||
|
function countDP(value) {
|
||||||
|
const match = DP_PATTERN.exec(value);
|
||||||
|
if(!match || !match[1]) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return match[1].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCounter(args) {
|
||||||
|
let start = 1;
|
||||||
|
let inc = 1;
|
||||||
|
let dp = 0;
|
||||||
|
if(args[0]) {
|
||||||
|
start = Number(args[0]);
|
||||||
|
dp = Math.max(dp, countDP(args[0]));
|
||||||
|
}
|
||||||
|
if(args[1]) {
|
||||||
|
inc = Number(args[1]);
|
||||||
|
dp = Math.max(dp, countDP(args[1]));
|
||||||
|
}
|
||||||
|
return {start, inc, dp};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToken(token) {
|
||||||
|
if(token === 'label') {
|
||||||
|
return {token: 'label'};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = token.indexOf(' ');
|
||||||
|
let type = null;
|
||||||
|
let args = null;
|
||||||
|
if(p === -1) {
|
||||||
|
type = token;
|
||||||
|
args = [];
|
||||||
|
} else {
|
||||||
|
type = token.substr(0, p);
|
||||||
|
args = token.substr(p + 1).split(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
if(type === 'inc') {
|
||||||
|
return parseCounter(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<' + token + '>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePattern(raw) {
|
||||||
|
const pattern = [];
|
||||||
|
let match = null;
|
||||||
|
let end = 0;
|
||||||
|
LABEL_PATTERN.lastIndex = 0;
|
||||||
|
while((match = LABEL_PATTERN.exec(raw))) {
|
||||||
|
if(match[1]) {
|
||||||
|
pattern.push(match[1]);
|
||||||
|
}
|
||||||
|
if(match[2]) {
|
||||||
|
pattern.push(parseToken(match[2]));
|
||||||
|
}
|
||||||
|
end = LABEL_PATTERN.lastIndex;
|
||||||
|
}
|
||||||
|
const remainder = raw.substr(end);
|
||||||
|
if(remainder) {
|
||||||
|
pattern.push(remainder);
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsePattern;
|
||||||
|
});
|
|
@ -0,0 +1,83 @@
|
||||||
|
defineDescribe('Label Pattern Parser', ['./LabelPatternParser'], (parser) => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
it('converts simple text', () => {
|
||||||
|
const parsed = parser('hello everybody');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
'hello everybody',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the empty case', () => {
|
||||||
|
const parsed = parser('');
|
||||||
|
expect(parsed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts tokens', () => {
|
||||||
|
const parsed = parser('foo <label> bar');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
'foo ',
|
||||||
|
{token: 'label'},
|
||||||
|
' bar',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts multiple tokens', () => {
|
||||||
|
const parsed = parser('foo <label> bar <label> baz');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
'foo ',
|
||||||
|
{token: 'label'},
|
||||||
|
' bar ',
|
||||||
|
{token: 'label'},
|
||||||
|
' baz',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty sequences', () => {
|
||||||
|
const parsed = parser('<label><label>');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
{token: 'label'},
|
||||||
|
{token: 'label'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes unrecognised tokens through unchanged', () => {
|
||||||
|
const parsed = parser('foo <nope>');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
'foo ',
|
||||||
|
'<nope>',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts counters', () => {
|
||||||
|
const parsed = parser('<inc 5, 2>a<inc 2, 1>b');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
{start: 5, inc: 2, dp: 0},
|
||||||
|
'a',
|
||||||
|
{start: 2, inc: 1, dp: 0},
|
||||||
|
'b',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults counters to increment = 1', () => {
|
||||||
|
const parsed = parser('<inc 5>');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
{start: 5, inc: 1, dp: 0},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults counters to start = 1', () => {
|
||||||
|
const parsed = parser('<inc>');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
{start: 1, inc: 1, dp: 0},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns decimal places to counters by their written precision', () => {
|
||||||
|
const parsed = parser('<inc 5.0, 2.00><inc 2.00, 1.0>');
|
||||||
|
expect(parsed).toEqual([
|
||||||
|
{start: jasmine.anything(), inc: jasmine.anything(), dp: 2},
|
||||||
|
{start: jasmine.anything(), inc: jasmine.anything(), dp: 2},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,10 +1,12 @@
|
||||||
define([
|
define([
|
||||||
'core/ArrayUtilities',
|
'core/ArrayUtilities',
|
||||||
'./Tokeniser',
|
'./Tokeniser',
|
||||||
|
'./LabelPatternParser',
|
||||||
'./CodeMirrorHints',
|
'./CodeMirrorHints',
|
||||||
], (
|
], (
|
||||||
array,
|
array,
|
||||||
Tokeniser,
|
Tokeniser,
|
||||||
|
labelPatternParser,
|
||||||
CMHints
|
CMHints
|
||||||
) => {
|
) => {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
@ -247,6 +249,23 @@ define([
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
(line) => { // autolabel
|
||||||
|
if(tokenKeyword(line[0]) !== 'autolabel') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = null;
|
||||||
|
if(tokenKeyword(line[1]) === 'off') {
|
||||||
|
raw = '<label>';
|
||||||
|
} else {
|
||||||
|
raw = joinLabel(line, 1);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'label pattern',
|
||||||
|
pattern: labelPatternParser(raw),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
(line) => { // block
|
(line) => { // block
|
||||||
if(tokenKeyword(line[0]) === 'end' && line.length === 1) {
|
if(tokenKeyword(line[0]) === 'end' && line.length === 1) {
|
||||||
return {type: 'block end'};
|
return {type: 'block end'};
|
||||||
|
|
|
@ -412,6 +412,32 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('converts autolabel commands', () => {
|
||||||
|
const parsed = parser.parse('autolabel "foo <label> bar"');
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{
|
||||||
|
type: 'label pattern',
|
||||||
|
ln: jasmine.anything(),
|
||||||
|
pattern: [
|
||||||
|
'foo ',
|
||||||
|
{token: 'label'},
|
||||||
|
' bar',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts autolabel off commands', () => {
|
||||||
|
const parsed = parser.parse('autolabel off');
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{
|
||||||
|
type: 'label pattern',
|
||||||
|
ln: jasmine.anything(),
|
||||||
|
pattern: [{token: 'label'}],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('converts "simultaneously" flow commands', () => {
|
it('converts "simultaneously" flow commands', () => {
|
||||||
const parsed = parser.parse('simultaneously:');
|
const parsed = parser.parse('simultaneously:');
|
||||||
expect(parsed.stages).toEqual([{
|
expect(parsed.stages).toEqual([{
|
||||||
|
|
|
@ -7,6 +7,7 @@ define([
|
||||||
'interface/Interface_spec',
|
'interface/Interface_spec',
|
||||||
'sequence/Tokeniser_spec',
|
'sequence/Tokeniser_spec',
|
||||||
'sequence/Parser_spec',
|
'sequence/Parser_spec',
|
||||||
|
'sequence/LabelPatternParser_spec',
|
||||||
'sequence/Generator_spec',
|
'sequence/Generator_spec',
|
||||||
'sequence/Renderer_spec',
|
'sequence/Renderer_spec',
|
||||||
'sequence/themes/Basic_spec',
|
'sequence/themes/Basic_spec',
|
||||||
|
|
Loading…
Reference in New Issue