bugfix storage, theming, validation, update launcher

This commit is contained in:
dragonwocky 2021-09-27 19:01:21 +10:00
parent 5bb8b5f3fc
commit b900c00641
17 changed files with 139 additions and 149 deletions

View File

@ -47,9 +47,13 @@ async function validate(mod) {
optional = false, optional = false,
} = {} } = {}
) => { ) => {
const test = await is(type === 'file' ? `repo/${mod._dir}/${value}` : value, type, { const test = await is(
extension, type === 'file' && value ? `repo/${mod._dir}/${value}` : value,
}); type,
{
extension,
}
);
if (!test) { if (!test) {
if (optional && (await is(value, 'undefined'))) return true; if (optional && (await is(value, 'undefined'))) return true;
if (error) _errors.push({ source: mod._dir, message: error }); if (error) _errors.push({ source: mod._dir, message: error });
@ -61,9 +65,14 @@ async function validate(mod) {
check('name', mod.name, 'string'), check('name', mod.name, 'string'),
check('id', mod.id, 'uuid'), check('id', mod.id, 'uuid'),
check('version', mod.version, 'semver'), check('version', mod.version, 'semver'),
check('environments', mod.environments, 'array').then((passed) => check('environments', mod.environments, 'array', { optional: true }).then((passed) => {
passed ? mod.environments.map((tag) => check('environments.env', tag, 'env')) : 0 if (!passed) return false;
), if (!mod.environments) {
mod.environments = env.supported;
return true;
}
return mod.environments.map((tag) => check('environments.env', tag, 'env'));
}),
check('description', mod.description, 'string'), check('description', mod.description, 'string'),
// file doubles for url here // file doubles for url here
check('preview', mod.preview, 'file', { optional: true }), check('preview', mod.preview, 'file', { optional: true }),
@ -202,9 +211,10 @@ async function validate(mod) {
/** /**
* list all available mods in the repo * list all available mods in the repo
* @param {function} filter - a function to filter out mods
* @returns {array} a validated list of mod.json objects * @returns {array} a validated list of mod.json objects
*/ */
export const list = async () => { export const list = async (filter = (mod) => true) => {
if (!_cache.length) { if (!_cache.length) {
for (const dir of await getJSON('repo/registry.json')) { for (const dir of await getJSON('repo/registry.json')) {
try { try {
@ -216,7 +226,9 @@ export const list = async () => {
} }
} }
} }
return _cache; const list = [];
for (const mod of _cache) if (await filter(mod)) list.push(mod);
return list;
}; };
/** /**
@ -263,6 +275,7 @@ export const enabled = async (id) => {
export const optionDefault = async (id, key) => { export const optionDefault = async (id, key) => {
const mod = await get(id), const mod = await get(id),
opt = mod.options.find((opt) => opt.key === key); opt = mod.options.find((opt) => opt.key === key);
if (!opt) return undefined;
switch (opt.type) { switch (opt.type) {
case 'toggle': case 'toggle':
case 'text': case 'text':
@ -272,6 +285,6 @@ export const optionDefault = async (id, key) => {
case 'select': case 'select':
return opt.values[0]; return opt.values[0];
case 'file': case 'file':
return undefined;
} }
return undefined;
}; };

View File

@ -24,15 +24,15 @@ export const get = (path, fallback = undefined) => {
if (!path.length) return fallback; if (!path.length) return fallback;
const namespace = path.shift(); const namespace = path.shift();
return new Promise((res, rej) => return new Promise((res, rej) =>
chrome.storage.sync.get([namespace], async (values) => { chrome.storage.sync.get(async (values) => {
let value = values[namespace]; let value = values[namespace];
while (path.length) { do {
value = value[path.shift()]; if (value === undefined) {
if (path.length && !value) {
value = fallback; value = fallback;
break; break;
} }
} value = value[path.shift()];
} while (path.length);
res(value ?? fallback); res(value ?? fallback);
}) })
); );
@ -54,17 +54,18 @@ export const set = (path, value) => {
} }
const pathClone = [...path], const pathClone = [...path],
namespace = path.shift(); namespace = path.shift();
chrome.storage.sync.get([namespace], async (values) => { chrome.storage.sync.get([], async (values) => {
const update = values[namespace] ?? {}; const update = values[namespace] ?? {};
let pointer = update, let pointer = update,
old; old;
while (true) { while (path.length) {
const key = path.shift(); const key = path.shift();
if (!path.length) { if (!path.length) {
old = pointer[key]; old = pointer[key];
pointer[key] = value; pointer[key] = value;
break; break;
} else if (!pointer[key]) pointer[key] = {}; }
pointer[key] = pointer[key] ?? {};
pointer = pointer[key]; pointer = pointer[key];
} }
chrome.storage.sync.set({ [namespace]: update }, () => { chrome.storage.sync.set({ [namespace]: update }, () => {
@ -82,14 +83,14 @@ export const set = (path, value) => {
/** /**
* create a wrapper for accessing a partition of the storage * create a wrapper for accessing a partition of the storage
* @param {array<string>} namespace - the path of keys to prefix all storage requests with * @param {array<string>} namespace - the path of keys to prefix all storage requests with
* @param {Function} [get] - the storage get function to be wrapped * @param {function} [get] - the storage get function to be wrapped
* @param {Function} [set] - the storage set function to be wrapped * @param {function} [set] - the storage set function to be wrapped
* @returns {object} an object with the wrapped get/set functions * @returns {object} an object with the wrapped get/set functions
*/ */
export const db = (namespace, get = get, set = set) => { export const db = (namespace, getFunc = get, setFunc = set) => {
return { return {
get: (path, fallback = undefined) => get([namespace, ...path], fallback), get: (path = [], fallback = undefined) => getFunc([...namespace, ...path], fallback),
set: (path, value) => set([namespace, ...path], value), set: (path, value) => setFunc([...namespace, ...path], value),
}; };
}; };

View File

@ -88,8 +88,9 @@ export const is = async (value, type, { extension = '' } = {}) => {
case 'undefined': case 'undefined':
case 'boolean': case 'boolean':
case 'number': case 'number':
case 'string':
return typeof value === type && extension; return typeof value === type && extension;
case 'string':
return typeof value === type && value.length && extension;
case 'alphanumeric': case 'alphanumeric':
case 'uuid': case 'uuid':
case 'semver': case 'semver':
@ -98,7 +99,7 @@ export const is = async (value, type, { extension = '' } = {}) => {
case 'color': case 'color':
return typeof value === 'string' && test(value, patterns[type]) && extension; return typeof value === 'string' && test(value, patterns[type]) && extension;
case 'file': case 'file':
return typeof value === 'string' && (await isFile(value)) && extension; return typeof value === 'string' && value && (await isFile(value)) && extension;
case 'env': case 'env':
return supported.includes(value); return supported.includes(value);
case 'optionType': case 'optionType':

View File

@ -128,7 +128,7 @@ export const empty = ($container) => {
* loads/applies a css stylesheet to the page * loads/applies a css stylesheet to the page
* @param {string} path - a url or within-the-enhancer filepath * @param {string} path - a url or within-the-enhancer filepath
*/ */
export const stylesheet = (path) => { export const loadStylesheet = (path) => {
render( render(
document.head, document.head,
html`<link html`<link

View File

@ -6,26 +6,37 @@
'use strict'; 'use strict';
import(chrome.runtime.getURL('api/_.mjs'));
// only load if user is logged into notion and viewing a page // only load if user is logged into notion and viewing a page
// if ( if (
// localStorage['LRU:KeyValueStore2:current-user-id'] && localStorage['LRU:KeyValueStore2:current-user-id'] &&
// location.pathname.split(/[/-]/g).reverse()[0].length === 32 location.pathname.split(/[/-]/g).reverse()[0].length === 32
// ) { ) {
// import(chrome.runtime.getURL('api.js')).then(async ({ web, registry }) => { import(chrome.runtime.getURL('api/_.mjs')).then(async (api) => {
// for (const mod of await registry.get((mod) => registry.isEnabled(mod.id))) { const { registry, storage, web } = api,
// for (const sheet of mod.css?.client || []) { profile = await storage.get(['currentprofile'], 'default');
// web.loadStylesheet(`repo/${mod._dir}/${sheet}`); for (const mod of await registry.list((mod) => registry.enabled(mod.id))) {
// } const db = storage.db(
// for (const script of mod.js?.client || []) { ['profiles', profile, mod.id],
// import(chrome.runtime.getURL(`repo/${mod._dir}/${script}`)); async (path, fallback = undefined) => {
// } if (path.length === 4) {
// } // profiles -> profile -> mod -> option
// const errors = await registry.errors(); fallback = (await registry.optionDefault(mod.id, path[3])) ?? fallback;
// if (errors.length) { }
// console.log('notion-enhancer errors:'); return storage.get(path, fallback);
// console.table(errors); }
// } );
// }); for (const sheet of mod.css?.client || []) {
// } web.loadStylesheet(`repo/${mod._dir}/${sheet}`);
}
for (let script of mod.js?.client || []) {
script = await import(chrome.runtime.getURL(`repo/${mod._dir}/${script}`));
script.default(api, db);
}
}
const errors = await registry.errors();
if (errors.length) {
console.log('[notion-enhancer] registry errors:');
console.table(errors);
}
});
}

View File

@ -2,7 +2,6 @@
"name": "bypass-preview", "name": "bypass-preview",
"id": "cb6fd684-f113-4a7a-9423-8f0f0cff069f", "id": "cb6fd684-f113-4a7a-9423-8f0f0cff069f",
"version": "0.2.0", "version": "0.2.0",
"environments": ["linux", "win32", "darwin", "extension"],
"description": "go straight to the normal full view when opening a page.", "description": "go straight to the normal full view when opening a page.",
"tags": ["extension", "automation"], "tags": ["extension", "automation"],
"authors": [ "authors": [

View File

@ -2,7 +2,6 @@
"name": "calendar-scroll", "name": "calendar-scroll",
"id": "b1c7db33-dfee-489a-a76c-0dd66f7ed29a", "id": "b1c7db33-dfee-489a-a76c-0dd66f7ed29a",
"version": "0.2.0", "version": "0.2.0",
"environments": ["linux", "win32", "darwin", "extension"],
"description": "add a button to scroll down to the current week in fullpage/infinite-scroll calendars.", "description": "add a button to scroll down to the current week in fullpage/infinite-scroll calendars.",
"tags": ["extension", "shortcut"], "tags": ["extension", "shortcut"],
"authors": [ "authors": [
@ -14,7 +13,7 @@
} }
], ],
"js": { "js": {
"client": ["client.mjs"] "client": ["client.mjs?"]
}, },
"css": { "css": {
"client": ["client.css"] "client": ["client.css"]

View File

@ -1,3 +0,0 @@
# menu
[theming mod link test](?view=mod&id=0f0bf8b6-eae6-4273-b307-8fc43f2ee082)

View File

@ -4,22 +4,22 @@
* (https://notion-enhancer.github.io/) under the MIT license * (https://notion-enhancer.github.io/) under the MIT license
*/ */
.enhancer--sidebarMenuTrigger { .enhancer--sidebarMenuLink {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
transition: background 20ms ease-in 0s; transition: background 20ms ease-in 0s;
cursor: pointer; cursor: pointer;
color: var(--theme--text_sidebar); color: var(--theme--text_ui);
} }
.enhancer--sidebarMenuTrigger:hover { .enhancer--sidebarMenuLink:hover {
background: var(--theme--button-hover); background: var(--theme--ui_interactive-hover);
} }
.enhancer--sidebarMenuTrigger svg { .enhancer--sidebarMenuLink svg {
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-left: 2px; margin-left: 2px;
} }
.enhancer--sidebarMenuTrigger > div { .enhancer--sidebarMenuLink > div {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 27px; min-height: 27px;
@ -27,7 +27,7 @@
padding: 2px 14px; padding: 2px 14px;
width: 100%; width: 100%;
} }
.enhancer--sidebarMenuTrigger > div > :first-child { .enhancer--sidebarMenuLink > div > :first-child {
flex-shrink: 0; flex-shrink: 0;
flex-grow: 0; flex-grow: 0;
border-radius: 3px; border-radius: 3px;
@ -38,19 +38,20 @@
justify-content: center; justify-content: center;
margin-right: 8px; margin-right: 8px;
} }
.enhancer--sidebarMenuTrigger > div > :nth-child(2) { .enhancer--sidebarMenuLink > div > :nth-child(2) {
flex: 1 1 auto; flex: 1 1 auto;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.enhancer--notifications {
.enhancer--sidebarMenuLink[data-has-notifications] {
color: var(--theme--text); color: var(--theme--text);
} }
.enhancer--notifications > div > :last-child { .enhancer--sidebarMenuLink > div > .enhancer--notificationBubble {
display: flex; display: flex;
} }
.enhancer--notifications > div > :last-child > div { .enhancer--sidebarMenuLink > div > .enhancer--notificationBubble > div {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -59,10 +60,10 @@
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
border-radius: 3px; border-radius: 3px;
color: var(--theme--tag_new-text); color: var(--theme--accent_red-text);
background: var(--theme--tag_new); background: var(--theme--accent_red);
} }
.enhancer--notifications > div > :last-child > div > span { .enhancer--sidebarMenuLink > div > .enhancer--notificationBubble > div > span {
margin-bottom: 1px; margin-bottom: 1px;
margin-left: -0.5px; margin-left: -0.5px;
} }

View File

@ -6,46 +6,44 @@
'use strict'; 'use strict';
const _id = 'a6621988-551d-495a-97d8-3c568bca2e9e'; export default async function (api, db) {
import { env, storage, web, fs, registry } from '../../api/_.mjs'; const { env, fs, registry, web } = api,
sidebarSelector = '.notion-sidebar-container .notion-sidebar > div:nth-child(4)';
await web.whenReady([sidebarSelector]);
const $sidebarLink = web.html`<div class="enhancer--sidebarMenuLink" role="button" tabindex="0">
<div>
<div>${await fs.getText('icon/colour.svg')}</div>
<div><div>notion-enhancer</div></div>
</div>
</div>`;
web.addHotkeyListener(await db.get(['hotkey']), env.openEnhancerMenu);
const sidebarSelector =
'#notion-app > div > div.notion-cursor-listener > div.notion-sidebar-container > div > div > div > div:nth-child(4)';
web.whenReady([sidebarSelector]).then(async () => {
const $enhancerSidebarElement = web.createElement(
web.html`<div class="enhancer--sidebarMenuTrigger" role="button" tabindex="0">
<div>
<div>${await fs.getText('icons/colour.svg')}</div>
<div><div>notion-enhancer</div></div>
</div>
</div>`
),
errors = await registry.errors(),
notifications = {
list: await fs.getJSON('https://notion-enhancer.github.io/notifications.json'),
dismissed: await storage.get(_id, 'notifications', []),
};
notifications.waiting = notifications.list.filter(
({ id }) => !notifications.dismissed.includes(id)
);
if (notifications.waiting.length + errors.length) {
$enhancerSidebarElement.classList.add('enhancer--notifications');
$enhancerSidebarElement.children[0].append(
web.createElement(
web.html`<div><div><span>${
notifications.waiting.length + errors.length
}</span></div></div>`
)
);
}
const setTheme = () => const setTheme = () =>
storage.set(_id, 'theme', document.querySelector('.notion-dark-theme') ? 'dark' : 'light'); db.set(['theme'], document.querySelector('.notion-dark-theme') ? 'dark' : 'light');
$enhancerSidebarElement.addEventListener('click', () => { $sidebarLink.addEventListener('click', () => {
setTheme().then(env.openEnhancerMenu); setTheme().then(env.openEnhancerMenu);
}); });
window.addEventListener('focus', setTheme); window.addEventListener('focus', setTheme);
window.addEventListener('blur', setTheme); window.addEventListener('blur', setTheme);
setTheme(); setTheme();
document.querySelector(sidebarSelector).appendChild($enhancerSidebarElement);
}); const errors = await registry.errors(),
web.addHotkeyListener(await storage.get(_id, 'hotkey.focustoggle'), env.openEnhancerMenu); notifications = {
cache: await db.get(['notifications'], []),
provider: await fs.getJSON('https://notion-enhancer.github.io/notifications.json'),
count: errors.length,
};
for (const notification of notifications.provider) {
if (!notifications.cache.includes(notification.id)) notifications.count++;
}
if (notifications.count) {
$sidebarLink.dataset.hasNotifications = true;
web.render(
$sidebarLink.children[0],
web.html`<div class="enhancer--notificationBubble"><div><span>${notifications.count}</span></div></div>`
);
}
web.render(document.querySelector(sidebarSelector), $sidebarLink);
}

View File

@ -1,2 +0,0 @@
<!-- https://fontawesome.com/icons/file?style=solid -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M224 136V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V160H248c-13.2 0-24-10.8-24-24zm160-14.1v6.1H256V0h6.1c6.4 0 12.5 2.5 17 7l97.9 98c4.5 4.5 7 10.6 7 16.9z"/></svg>

Before

Width:  |  Height:  |  Size: 343 B

View File

@ -2,7 +2,6 @@
"name": "menu", "name": "menu",
"id": "a6621988-551d-495a-97d8-3c568bca2e9e", "id": "a6621988-551d-495a-97d8-3c568bca2e9e",
"version": "0.11.0", "version": "0.11.0",
"environments": ["linux", "win32", "darwin", "extension"],
"description": "the enhancer's graphical menu, related buttons and shortcuts.", "description": "the enhancer's graphical menu, related buttons and shortcuts.",
"tags": ["core"], "tags": ["core"],
"authors": [ "authors": [
@ -14,24 +13,16 @@
} }
], ],
"css": { "css": {
"frame": ["tooltips.css"], "client": ["client.css"],
"client": ["client.css", "tooltips.css"], "menu": ["menu.css", "markdown.css"]
"menu": ["menu.css", "markdown.css", "tooltips.css"]
}, },
"js": { "js": {
"client": ["client.mjs"] "client": ["client.mjs"]
}, },
"options": [ "options": [
{
"type": "toggle",
"key": "themes.autoresolve",
"label": "auto-resolve theme conflicts",
"value": true,
"tooltip": "when a theme is enabled any other themes of the same mode (light/dark) will be disabled"
},
{ {
"type": "text", "type": "text",
"key": "hotkey.focustoggle", "key": "hotkey",
"label": "toggle hotkey", "label": "toggle hotkey",
"value": "Ctrl+Alt+E", "value": "Ctrl+Alt+E",
"tooltip": "toggles focus between notion & the enhancer menu" "tooltip": "toggles focus between notion & the enhancer menu"

View File

@ -1,20 +0,0 @@
/*
* notion-enhancer core: tooltips
* (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
.enhancer--tooltip {
position: absolute;
background: var(--theme--tooltip);
color: var(--theme--tooltip-text);
font-size: var(--theme--font_ui_small-size);
padding: 0.15rem 0.4rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
border-radius: 3px;
max-width: 20rem;
display: none;
}
.enhancer--tooltip p {
margin: 0.25rem 0;
}

View File

@ -6,11 +6,14 @@
'use strict'; 'use strict';
import { web } from '../../api/_.mjs'; export default function (api, db) {
const { web } = api;
const $root = document.querySelector(':root'); const $root = document.querySelector(':root');
web.addDocumentObserver((mutation) => { $root.classList[document.body.classList.contains('dark') ? 'add' : 'remove']('dark');
if (mutation.target === document.body) { web.addDocumentObserver((mutation) => {
$root.classList[document.body.classList.contains('dark') ? 'add' : 'remove']('dark'); if (mutation.target === document.body) {
} $root.classList[document.body.classList.contains('dark') ? 'add' : 'remove']('dark');
}); }
});
}

View File

@ -2,7 +2,6 @@
"name": "theming", "name": "theming",
"id": "0f0bf8b6-eae6-4273-b307-8fc43f2ee082", "id": "0f0bf8b6-eae6-4273-b307-8fc43f2ee082",
"version": "0.11.0", "version": "0.11.0",
"environments": ["linux", "win32", "darwin", "extension"],
"description": "the default theme variables, required by other themes & extensions.", "description": "the default theme variables, required by other themes & extensions.",
"tags": ["core"], "tags": ["core"],
"authors": [ "authors": [

View File

@ -153,7 +153,7 @@ body,
.notion-overlay-container.notion-default-overlay-container .notion-overlay-container.notion-default-overlay-container
[style*='display: flex'] [style*='display: flex']
> [style*='position: relative; max-width:'][style*='overflow: hidden']:not([style*='border-radius: 3px;'][style*='position: relative; max-width: calc(100vw - 24px); box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 4px; overflow: hidden;'][style*='padding: 4px 8px; font-size: 12px; line-height: 1.4; font-weight: 500; white-space: nowrap;']), > [style*='position: relative; max-width:'][style*='overflow: hidden']:not([style*='border-radius: 3px;'][style*='position: relative; max-width: calc(100vw - 24px); box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 4px; overflow: hidden;'][style*='padding: 4px 8px; font-size: 12px; line-height: 1.4; font-weight: 500;']),
.notion-overlay-container.notion-default-overlay-container .notion-overlay-container.notion-default-overlay-container
[style*='display: flex'] [style*='display: flex']
> [style*='position: relative; max-width:'][style*='overflow: hidden'] > [style*='position: relative; max-width:'][style*='overflow: hidden']
@ -391,13 +391,13 @@ body,
} }
.notion-overlay-container .notion-overlay-container
[style*='border-radius: 3px;'][style*='position: relative; max-width: calc(100vw - 24px); box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 4px; overflow: hidden;'][style*='padding: 4px 8px; font-size: 12px; line-height: 1.4; font-weight: 500; white-space: nowrap;'] { [style*='border-radius: 3px;'][style*='position: relative; max-width: calc(100vw - 24px); box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 4px; overflow: hidden;'][style*='padding: 4px 8px; font-size: 12px; line-height: 1.4; font-weight: 500;'] {
background: var(--theme--ui_tooltip) !important; background: var(--theme--ui_tooltip) !important;
box-shadow: var(--theme--ui_shadow) 0px 1px 4px !important; box-shadow: var(--theme--ui_shadow) 0px 1px 4px !important;
color: var(--theme--ui_tooltip-title) !important; color: var(--theme--ui_tooltip-title) !important;
} }
.notion-overlay-container .notion-overlay-container
[style*='border-radius: 3px;'][style*='position: relative; max-width: calc(100vw - 24px); box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 4px; overflow: hidden;'][style*='padding: 4px 8px; font-size: 12px; line-height: 1.4; font-weight: 500; white-space: nowrap;'] [style*='border-radius: 3px;'][style*='position: relative; max-width: calc(100vw - 24px); box-shadow: rgba(0, 0, 0, 0.3) 0px 1px 4px; overflow: hidden;'][style*='padding: 4px 8px; font-size: 12px; line-height: 1.4; font-weight: 500;']
[style*='color: '] { [style*='color: '] {
color: var(--theme--ui_tooltip-description) !important; color: var(--theme--ui_tooltip-description) !important;
} }

View File

@ -2,7 +2,6 @@
"name": "tweaks", "name": "tweaks",
"id": "5174a483-c88d-4bf8-a95f-35cd330b76e2", "id": "5174a483-c88d-4bf8-a95f-35cd330b76e2",
"version": "0.2.0", "version": "0.2.0",
"environments": ["linux", "win32", "darwin", "extension"],
"description": "common style/layout changes and custom inserts.", "description": "common style/layout changes and custom inserts.",
"tags": ["extension", "customisation"], "tags": ["extension", "customisation"],
"authors": [ "authors": [