mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-06 09:09:38 +03:00
Compare commits
16 Commits
vectors
...
further_th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3d0a4a07 | ||
|
|
5038d124e1 | ||
|
|
f7890c2dd9 | ||
|
|
45ae03ceac | ||
|
|
aa0a8dda11 | ||
|
|
120ee38383 | ||
|
|
cd84074cdf | ||
|
|
4949520194 | ||
|
|
1b17bb3929 | ||
|
|
9fcfc762ec | ||
|
|
c32b1686a9 | ||
|
|
36649a6188 | ||
|
|
ff59bbdc07 | ||
|
|
4dc443b7df | ||
|
|
19f02d927e | ||
|
|
da7bedd2e4 |
7
.github/translators.txt
vendored
7
.github/translators.txt
vendored
@@ -521,3 +521,10 @@ setiawan setiawan (culture.setiawan) :: Indonesian
|
||||
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||
CriedHero :: Chinese Simplified
|
||||
Henrik (henrik2105) :: Norwegian Bokmal
|
||||
FoW (fofwisdom) :: Korean
|
||||
serinf-lauza :: French
|
||||
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
|
||||
Shadluk Avan (quldosh) :: Uzbek
|
||||
Marci (MartonPoto) :: Hungarian
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Theming\ThemeService;
|
||||
use BookStack\Theming\ThemeViews;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ThemeServiceProvider extends ServiceProvider
|
||||
@@ -24,7 +26,23 @@ class ThemeServiceProvider extends ServiceProvider
|
||||
{
|
||||
// Boot up the theme system
|
||||
$themeService = $this->app->make(ThemeService::class);
|
||||
$viewFactory = $this->app->make('view');
|
||||
if (!$themeService->getTheme()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$themeService->loadModules();
|
||||
$themeService->readThemeActions();
|
||||
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
||||
|
||||
$themeViews = new ThemeViews();
|
||||
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
|
||||
$themeViews->registerViewPathsForTheme($viewFactory->getFinder(), $themeService->getModules());
|
||||
if ($themeViews->hasRegisteredViews()) {
|
||||
$viewFactory->share('__themeViews', $themeViews);
|
||||
Blade::directive('include', function ($expression) {
|
||||
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +81,7 @@ function setting(?string $key = null, mixed $default = null): mixed
|
||||
|
||||
/**
|
||||
* Get a path to a theme resource.
|
||||
* Returns null if a theme is not configured and
|
||||
* therefore a full path is not available for use.
|
||||
* Returns null if a theme is not configured, and therefore a full path is not available for use.
|
||||
*/
|
||||
function theme_path(string $path = ''): ?string
|
||||
{
|
||||
|
||||
@@ -22,18 +22,6 @@ return [
|
||||
// Callback URL for social authentication methods
|
||||
'callback_url' => env('APP_URL', false),
|
||||
|
||||
// LLM Service
|
||||
// Options: openai
|
||||
'llm' => env('LLM_SERVICE', ''),
|
||||
|
||||
// OpenAI API-compatible service details
|
||||
'openai' => [
|
||||
'endpoint' => env('OPENAI_ENDPOINT', 'https://api.openai.com'),
|
||||
'key' => env('OPENAI_KEY', ''),
|
||||
'embedding_model' => env('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small'),
|
||||
'query_model' => env('OPENAI_QUERY_MODEL', 'gpt-4o'),
|
||||
],
|
||||
|
||||
'github' => [
|
||||
'client_id' => env('GITHUB_APP_ID', false),
|
||||
'client_secret' => env('GITHUB_APP_SECRET', false),
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
// Join up possible view locations
|
||||
$viewPaths = [realpath(base_path('resources/views'))];
|
||||
if ($theme = env('APP_THEME', false)) {
|
||||
array_unshift($viewPaths, base_path('themes/' . $theme));
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
// App theme
|
||||
@@ -26,7 +20,7 @@ return [
|
||||
// Most templating systems load templates from disk. Here you may specify
|
||||
// an array of paths that should be checked for your views. Of course
|
||||
// the usual Laravel view path has already been registered for you.
|
||||
'paths' => $viewPaths,
|
||||
'paths' => [realpath(base_path('resources/views'))],
|
||||
|
||||
// Compiled View Path
|
||||
// This option determines where all the compiled Blade templates will be
|
||||
|
||||
305
app/Console/Commands/InstallModuleCommand.php
Normal file
305
app/Console/Commands/InstallModuleCommand.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeModule;
|
||||
use BookStack\Theming\ThemeModuleException;
|
||||
use BookStack\Theming\ThemeModuleManager;
|
||||
use BookStack\Theming\ThemeModuleZip;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class InstallModuleCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:install-module
|
||||
{location : The URL or path of the module file}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Install a module to the currently configured theme';
|
||||
|
||||
protected array $cleanupActions = [];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$location = $this->argument('location');
|
||||
|
||||
// Get the ZIP file containing the module files
|
||||
$zipPath = $this->getPathToZip($location);
|
||||
if (!$zipPath) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Validate module zip file (metadata, size, etc...) and get module instance
|
||||
$zip = new ThemeModuleZip($zipPath);
|
||||
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
|
||||
if (!$themeModule) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the theme folder in use, attempting to create one if no active theme in use
|
||||
$themeFolder = $this->getThemeFolder();
|
||||
if (!$themeFolder) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the modules folder of the theme, attempting to create it if not existing,
|
||||
// and create a new module manager instance.
|
||||
$moduleFolder = $this->getModuleFolder($themeFolder);
|
||||
if (!$moduleFolder) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$manager = new ThemeModuleManager($moduleFolder);
|
||||
|
||||
// Handle existing modules with the same name
|
||||
$exitingModulesWithName = $manager->getByName($themeModule->name);
|
||||
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
|
||||
if (!$shouldContinue) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract module ZIP into the theme modules folder
|
||||
try {
|
||||
$newModule = $manager->addFromZip($themeModule->name, $zip);
|
||||
} catch (ThemeModuleException $exception) {
|
||||
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!");
|
||||
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
|
||||
$this->cleanup();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ThemeModule[] $existingModules
|
||||
*/
|
||||
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
|
||||
{
|
||||
if (count($existingModules) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->warn("The following modules already exist with the same name:");
|
||||
foreach ($existingModules as $folder => $module) {
|
||||
$this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}");
|
||||
}
|
||||
$this->line('');
|
||||
|
||||
$choices = ['Cancel module install', 'Add alongside existing module'];
|
||||
if (count($existingModules) === 1) {
|
||||
$choices[] = 'Replace existing module';
|
||||
}
|
||||
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
|
||||
if ($choice === 'Cancel module install') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($choice === 'Replace existing module') {
|
||||
$existingModuleFolder = array_key_first($existingModules);
|
||||
$this->info("Replacing existing module in {$existingModuleFolder} folder");
|
||||
$manager->deleteModuleFolder($existingModuleFolder);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getModuleFolder(string $themeFolder): string|null
|
||||
{
|
||||
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
|
||||
|
||||
if (file_exists($path) && !is_dir($path)) {
|
||||
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$created = mkdir($path, 0755, true);
|
||||
if (!$created) {
|
||||
$this->error("ERROR: Failed to create a modules folder at {$path}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function getThemeFolder(): string|null
|
||||
{
|
||||
$path = theme_path('');
|
||||
if (!$path || !is_dir($path)) {
|
||||
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
|
||||
if (!$shouldCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$folder = 'custom';
|
||||
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
|
||||
$folder = 'custom-' . Str::random(4);
|
||||
}
|
||||
|
||||
$path = base_path("themes/{$folder}");
|
||||
$created = mkdir($path, 0755, true);
|
||||
if (!$created) {
|
||||
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->info("Created theme folder at {$path}");
|
||||
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
|
||||
{
|
||||
if (!$zip->exists()) {
|
||||
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
|
||||
$this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$themeModule = $zip->getModuleInstance();
|
||||
} catch (ThemeModuleException $exception) {
|
||||
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $themeModule;
|
||||
}
|
||||
|
||||
protected function downloadModuleFile(string $location): string|null
|
||||
{
|
||||
$httpRequests = app()->make(HttpRequestService::class);
|
||||
$client = $httpRequests->buildClient(30, ['stream' => true]);
|
||||
$originalUrl = parse_url($location);
|
||||
$currentLocation = $location;
|
||||
$maxRedirects = 3;
|
||||
$redirectCount = 0;
|
||||
|
||||
// Follow redirects up to 3 times for the same hostname
|
||||
do {
|
||||
$resp = $client->sendRequest(new Request('GET', $currentLocation));
|
||||
$statusCode = $resp->getStatusCode();
|
||||
|
||||
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
|
||||
$redirectLocation = $resp->getHeaderLine('Location');
|
||||
if ($redirectLocation) {
|
||||
$redirectUrl = parse_url($redirectLocation);
|
||||
if (
|
||||
($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
|
||||
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
|
||||
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '')
|
||||
) {
|
||||
$currentLocation = $redirectLocation;
|
||||
$redirectCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
} while (true);
|
||||
|
||||
if ($resp->getStatusCode() >= 300) {
|
||||
$this->error("ERROR: Failed to download module from {$location}");
|
||||
$this->error("Download failed with status code {$resp->getStatusCode()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
|
||||
$fileHandle = fopen($tempFile, 'w');
|
||||
$respBody = $resp->getBody();
|
||||
$size = 0;
|
||||
$maxSize = 50 * 1024 * 1024;
|
||||
|
||||
while (!$respBody->eof()) {
|
||||
fwrite($fileHandle, $respBody->read(1024));
|
||||
$size += 1024;
|
||||
if ($size > $maxSize) {
|
||||
fclose($fileHandle);
|
||||
unlink($tempFile);
|
||||
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fileHandle);
|
||||
|
||||
$this->cleanupActions[] = function () use ($tempFile) {
|
||||
unlink($tempFile);
|
||||
};
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
protected function getPathToZip(string $location): string|null
|
||||
{
|
||||
$lowerLocation = strtolower($location);
|
||||
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
|
||||
|
||||
if ($isRemote) {
|
||||
// Warning about fetching from source
|
||||
$host = parse_url($location, PHP_URL_HOST);
|
||||
$this->warn("This will download a module from {$host}. Modules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
|
||||
$trustHost = $this->confirm('Are you sure you trust this source?');
|
||||
if (!$trustHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the connection is http. If so, warn the user.
|
||||
if (str_starts_with($lowerLocation, 'http://')) {
|
||||
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
|
||||
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Download ZIP and get its location
|
||||
return $this->downloadModuleFile($location);
|
||||
}
|
||||
|
||||
// Validate file and get full location
|
||||
$zipPath = realpath($location);
|
||||
if (!$zipPath || !is_file($zipPath)) {
|
||||
$this->error("ERROR: Module file not found at {$location}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
|
||||
protected function cleanup(): void
|
||||
{
|
||||
foreach ($this->cleanupActions as $action) {
|
||||
$action();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Search\Queries\SearchVector;
|
||||
use BookStack\Search\Queries\StoreEntityVectorsJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RegenerateVectorsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:regenerate-vectors';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Re-index vectors for all content in the system';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(EntityProvider $entityProvider)
|
||||
{
|
||||
// TODO - Add confirmation before run regarding deletion/time/effort/api-cost etc...
|
||||
SearchVector::query()->delete();
|
||||
|
||||
$types = $entityProvider->all();
|
||||
foreach ($types as $type => $typeInstance) {
|
||||
$this->info("Creating jobs to store vectors for {$type} data...");
|
||||
/** @var Entity[] $entities */
|
||||
$typeInstance->newQuery()->chunkById(100, function ($entities) {
|
||||
foreach ($entities as $entity) {
|
||||
dispatch(new StoreEntityVectorsJob($entity));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Search\Queries\Services\VectorQueryService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EntityVectorGenerator
|
||||
{
|
||||
public function __construct(
|
||||
protected VectorQueryServiceProvider $vectorQueryServiceProvider
|
||||
) {
|
||||
}
|
||||
|
||||
public function generateAndStore(Entity $entity): void
|
||||
{
|
||||
$vectorService = $this->vectorQueryServiceProvider->get();
|
||||
|
||||
$text = $this->entityToPlainText($entity);
|
||||
$chunks = $this->chunkText($text);
|
||||
$embeddings = $this->chunksToEmbeddings($chunks, $vectorService);
|
||||
|
||||
$this->deleteExistingEmbeddingsForEntity($entity);
|
||||
$this->storeEmbeddings($embeddings, $chunks, $entity);
|
||||
}
|
||||
|
||||
protected function deleteExistingEmbeddingsForEntity(Entity $entity): void
|
||||
{
|
||||
SearchVector::query()
|
||||
->where('entity_type', '=', $entity->getMorphClass())
|
||||
->where('entity_id', '=', $entity->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
protected function storeEmbeddings(array $embeddings, array $textChunks, Entity $entity): void
|
||||
{
|
||||
$toInsert = [];
|
||||
|
||||
foreach ($embeddings as $index => $embedding) {
|
||||
$text = $textChunks[$index];
|
||||
$toInsert[] = [
|
||||
'entity_id' => $entity->id,
|
||||
'entity_type' => $entity->getMorphClass(),
|
||||
'embedding' => DB::raw('VEC_FROMTEXT("[' . implode(',', $embedding) . ']")'),
|
||||
'text' => $text,
|
||||
];
|
||||
}
|
||||
|
||||
$chunks = array_chunk($toInsert, 500);
|
||||
foreach ($chunks as $chunk) {
|
||||
SearchVector::query()->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $chunks
|
||||
* @return float[] array
|
||||
*/
|
||||
protected function chunksToEmbeddings(array $chunks, VectorQueryService $vectorQueryService): array
|
||||
{
|
||||
$embeddings = [];
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
$embeddings[$index] = $vectorQueryService->generateEmbeddings($chunk);
|
||||
}
|
||||
return $embeddings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
protected function chunkText(string $text): array
|
||||
{
|
||||
return (new TextChunker(500, ["\n", '.', ' ', '']))->chunk($text);
|
||||
}
|
||||
|
||||
protected function entityToPlainText(Entity $entity): string
|
||||
{
|
||||
$tags = $entity->tags()->get();
|
||||
$tagText = $tags->map(function (Tag $tag) {
|
||||
return $tag->name . ': ' . $tag->value;
|
||||
})->join('\n');
|
||||
|
||||
return $entity->name . "\n{$tagText}\n" . $entity->{$entity->textField};
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use Exception;
|
||||
|
||||
class LlmQueryRunner
|
||||
{
|
||||
public function __construct(
|
||||
protected VectorQueryServiceProvider $vectorQueryServiceProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a query against the configured LLM to produce a text response.
|
||||
* @param VectorSearchResult[] $vectorResults
|
||||
* @throws Exception
|
||||
*/
|
||||
public function run(string $query, array $vectorResults): string
|
||||
{
|
||||
$queryService = $this->vectorQueryServiceProvider->get();
|
||||
|
||||
$matchesText = array_values(array_map(fn (VectorSearchResult $result) => $result->matchText, $vectorResults));
|
||||
return $queryService->query($query, $matchesText);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Search\SearchRunner;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QueryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SearchRunner $searchRunner,
|
||||
) {
|
||||
// TODO - Check via testing
|
||||
$this->middleware(function ($request, $next) {
|
||||
if (!VectorQueryServiceProvider::isEnabled()) {
|
||||
$this->showPermissionError('/');
|
||||
}
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to start a vector/LLM-based query search.
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
$query = $request->get('ask', '');
|
||||
|
||||
// TODO - Set page title
|
||||
|
||||
return view('search.query', [
|
||||
'query' => $query,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a vector/LLM-based query search.
|
||||
*/
|
||||
public function run(Request $request, VectorSearchRunner $searchRunner, LlmQueryRunner $llmRunner)
|
||||
{
|
||||
// TODO - Rate limiting
|
||||
$query = $request->get('query', '');
|
||||
|
||||
return response()->eventStream(function () use ($query, $searchRunner, $llmRunner) {
|
||||
$results = $query ? $searchRunner->run($query) : [];
|
||||
|
||||
$entities = [];
|
||||
foreach ($results as $result) {
|
||||
$entityKey = $result->entity->getMorphClass() . ':' . $result->entity->id;
|
||||
if (!isset($entities[$entityKey])) {
|
||||
$entities[$entityKey] = $result->entity;
|
||||
}
|
||||
}
|
||||
|
||||
yield ['view' => view('entities.list', ['entities' => $entities])->render()];
|
||||
|
||||
yield ['result' => $llmRunner->run($query, $results)];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property string $text
|
||||
* @property string $embedding
|
||||
*/
|
||||
class SearchVector extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
|
||||
->whereColumn('search_vectors.entity_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Queries\Services;
|
||||
|
||||
use BookStack\Http\HttpRequestService;
|
||||
|
||||
class OpenAiVectorQueryService implements VectorQueryService
|
||||
{
|
||||
protected string $key;
|
||||
protected string $endpoint;
|
||||
protected string $embeddingModel;
|
||||
protected string $queryModel;
|
||||
|
||||
public function __construct(
|
||||
protected array $options,
|
||||
protected HttpRequestService $http,
|
||||
) {
|
||||
// TODO - Some kind of validation of options
|
||||
$this->key = $this->options['key'] ?? '';
|
||||
$this->endpoint = $this->options['endpoint'] ?? '';
|
||||
$this->embeddingModel = $this->options['embedding_model'] ?? '';
|
||||
$this->queryModel = $this->options['query_model'] ?? '';
|
||||
}
|
||||
|
||||
protected function jsonRequest(string $method, string $uri, array $data): array
|
||||
{
|
||||
$fullUrl = rtrim($this->endpoint, '/') . '/' . ltrim($uri, '/');
|
||||
$client = $this->http->buildClient(30);
|
||||
$request = $this->http->jsonRequest($method, $fullUrl, $data)
|
||||
->withHeader('Authorization', 'Bearer ' . $this->key);
|
||||
|
||||
$response = $client->sendRequest($request);
|
||||
return json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
|
||||
public function generateEmbeddings(string $text): array
|
||||
{
|
||||
$response = $this->jsonRequest('POST', 'v1/embeddings', [
|
||||
'input' => $text,
|
||||
'model' => $this->embeddingModel,
|
||||
]);
|
||||
|
||||
return $response['data'][0]['embedding'];
|
||||
}
|
||||
|
||||
public function query(string $input, array $context): string
|
||||
{
|
||||
$formattedContext = implode("\n", $context);
|
||||
|
||||
$response = $this->jsonRequest('POST', 'v1/chat/completions', [
|
||||
'model' => $this->queryModel,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'developer',
|
||||
'content' => 'You are a helpful assistant providing search query responses. Be specific, factual and to-the-point in response. Don\'t try to converse or continue the conversation.'
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Provide a response to the below given QUERY using the below given CONTEXT. The CONTEXT is split into parts via lines. Ignore any nonsensical lines of CONTEXT.\nQUERY: {$input}\n\nCONTEXT: {$formattedContext}",
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
return $response['choices'][0]['message']['content'] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Queries\Services;
|
||||
|
||||
interface VectorQueryService
|
||||
{
|
||||
/**
|
||||
* Generate embedding vectors from the given chunk of text.
|
||||
* @return float[]
|
||||
*/
|
||||
public function generateEmbeddings(string $text): array;
|
||||
|
||||
/**
|
||||
* Query the LLM service using the given user input, and
|
||||
* relevant context text retrieved locally via a vector search.
|
||||
* Returns the response output text from the LLM.
|
||||
*
|
||||
* @param string[] $context
|
||||
*/
|
||||
public function query(string $input, array $context): string;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class StoreEntityVectorsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
protected Entity $entity
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(EntityVectorGenerator $generator): void
|
||||
{
|
||||
$generator->generateAndStore($this->entity);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Splits a given string into smaller chunks based on specified delimiters
|
||||
* and a predefined maximum chunk size. This will work through the given delimiters
|
||||
* to break down text further and further to fit into the chunk size.
|
||||
*
|
||||
* The last delimiter is always an empty string to ensure text can always be broken down.
|
||||
*/
|
||||
class TextChunker
|
||||
{
|
||||
public function __construct(
|
||||
protected int $chunkSize,
|
||||
protected array $delimiterOrder,
|
||||
) {
|
||||
if (count($this->delimiterOrder) === 0 || $this->delimiterOrder[count($this->delimiterOrder) - 1] !== '') {
|
||||
$this->delimiterOrder[] = '';
|
||||
}
|
||||
|
||||
if ($this->chunkSize < 1) {
|
||||
throw new InvalidArgumentException('Chunk size must be greater than 0');
|
||||
}
|
||||
}
|
||||
|
||||
public function chunk(string $text): array
|
||||
{
|
||||
$delimiter = $this->delimiterOrder[0];
|
||||
$delimiterLength = strlen($delimiter);
|
||||
$lines = ($delimiter === '') ? str_split($text, $this->chunkSize) : explode($delimiter, $text);
|
||||
|
||||
$cChunk = ''; // Current chunk
|
||||
$cLength = 0; // Current chunk length
|
||||
$chunks = []; // Chunks to return
|
||||
$lDelim = ''; // Last delimiter
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
$lineLength = strlen($line);
|
||||
if ($cLength + $lineLength + $delimiterLength <= $this->chunkSize) {
|
||||
$cChunk .= $line . $delimiter;
|
||||
$cLength += $lineLength + $delimiterLength;
|
||||
$lDelim = $delimiter;
|
||||
} else if ($lineLength <= $this->chunkSize) {
|
||||
$chunks[] = trim($cChunk, $delimiter);
|
||||
$cChunk = $line . $delimiter;
|
||||
$cLength = $lineLength + $delimiterLength;
|
||||
$lDelim = $delimiter;
|
||||
} else {
|
||||
$subChunks = new static($this->chunkSize, array_slice($this->delimiterOrder, 1));
|
||||
$subDelimiter = $this->delimiterOrder[1] ?? '';
|
||||
$subDelimiterLength = strlen($subDelimiter);
|
||||
foreach ($subChunks->chunk($line) as $subChunk) {
|
||||
$chunkLength = strlen($subChunk);
|
||||
if ($cLength + $chunkLength + $subDelimiterLength <= $this->chunkSize) {
|
||||
$cChunk .= $subChunk . $subDelimiter;
|
||||
$cLength += $chunkLength + $subDelimiterLength;
|
||||
$lDelim = $subDelimiter;
|
||||
} else {
|
||||
$chunks[] = trim($cChunk, $lDelim);
|
||||
$cChunk = $subChunk . $subDelimiter;
|
||||
$cLength = $chunkLength + $subDelimiterLength;
|
||||
$lDelim = $subDelimiter;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($cChunk !== '') {
|
||||
$chunks[] = trim($cChunk, $lDelim);
|
||||
}
|
||||
|
||||
return $chunks;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Search\Queries\Services\OpenAiVectorQueryService;
|
||||
use BookStack\Search\Queries\Services\VectorQueryService;
|
||||
|
||||
class VectorQueryServiceProvider
|
||||
{
|
||||
public function __construct(
|
||||
protected HttpRequestService $http,
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(): VectorQueryService
|
||||
{
|
||||
$service = $this->getServiceName();
|
||||
|
||||
if ($service === 'openai') {
|
||||
return new OpenAiVectorQueryService(config('services.openai'), $this->http);
|
||||
}
|
||||
|
||||
throw new \Exception("No '{$service}' LLM service found");
|
||||
}
|
||||
|
||||
protected static function getServiceName(): string
|
||||
{
|
||||
return strtolower(config('services.llm'));
|
||||
}
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return !empty(static::getServiceName());
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
readonly class VectorSearchResult
|
||||
{
|
||||
public function __construct(
|
||||
public Entity $entity,
|
||||
public float $distance,
|
||||
public string $matchText
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Queries;
|
||||
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Exception;
|
||||
|
||||
class VectorSearchRunner
|
||||
{
|
||||
public function __construct(
|
||||
protected VectorQueryServiceProvider $vectorQueryServiceProvider,
|
||||
protected PermissionApplicator $permissions,
|
||||
protected MixedEntityListLoader $entityLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a vector search query to find results across entities.
|
||||
* @return VectorSearchResult[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function run(string $query): array
|
||||
{
|
||||
$queryService = $this->vectorQueryServiceProvider->get();
|
||||
$queryVector = $queryService->generateEmbeddings($query);
|
||||
|
||||
// TODO - Test permissions applied
|
||||
$topMatchesQuery = SearchVector::query()->select('text', 'entity_type', 'entity_id')
|
||||
->selectRaw('VEC_DISTANCE_COSINE(VEC_FROMTEXT("[' . implode(',', $queryVector) . ']"), embedding) as distance')
|
||||
->orderBy('distance', 'asc')
|
||||
->having('distance', '<', 0.6)
|
||||
->limit(10);
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($topMatchesQuery, 'search_vectors', 'entity_id', 'entity_type');
|
||||
$topMatches = $query->get();
|
||||
|
||||
$this->entityLoader->loadIntoRelations($topMatches->all(), 'entity', true);
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($topMatches as $match) {
|
||||
if ($match->relationLoaded('entity')) {
|
||||
$results[] = new VectorSearchResult(
|
||||
$match->getRelation('entity'),
|
||||
$match->getAttribute('distance'),
|
||||
$match->getAttribute('text'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Queries\QueryPopular;
|
||||
use BookStack\Entities\Tools\SiblingFetcher;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Search\Queries\VectorSearchRunner;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Search\Queries\StoreEntityVectorsJob;
|
||||
use BookStack\Search\Queries\VectorQueryServiceProvider;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use DOMNode;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -27,7 +25,7 @@ class SearchIndex
|
||||
public static string $softDelimiters = ".-";
|
||||
|
||||
public function __construct(
|
||||
protected EntityProvider $entityProvider,
|
||||
protected EntityProvider $entityProvider
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,10 +37,6 @@ class SearchIndex
|
||||
$this->deleteEntityTerms($entity);
|
||||
$terms = $this->entityToTermDataArray($entity);
|
||||
$this->insertTerms($terms);
|
||||
|
||||
if (VectorQueryServiceProvider::isEnabled()) {
|
||||
dispatch(new StoreEntityVectorsJob($entity));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,15 +47,9 @@ class SearchIndex
|
||||
public function indexEntities(array $entities): void
|
||||
{
|
||||
$terms = [];
|
||||
$vectorQueryEnabled = VectorQueryServiceProvider::isEnabled();
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$entityTerms = $this->entityToTermDataArray($entity);
|
||||
array_push($terms, ...$entityTerms);
|
||||
|
||||
if ($vectorQueryEnabled) {
|
||||
dispatch(new StoreEntityVectorsJob($entity));
|
||||
}
|
||||
}
|
||||
|
||||
$this->insertTerms($terms);
|
||||
|
||||
@@ -5,21 +5,22 @@ namespace BookStack\Theming;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Util\FilePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ThemeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Serve a public file from the configured theme.
|
||||
*/
|
||||
public function publicFile(string $theme, string $path)
|
||||
public function publicFile(string $theme, string $path): StreamedResponse
|
||||
{
|
||||
$cleanPath = FilePathNormalizer::normalize($path);
|
||||
if ($theme !== Theme::getTheme() || !$cleanPath) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$filePath = theme_path("public/{$cleanPath}");
|
||||
if (!file_exists($filePath)) {
|
||||
$filePath = Theme::findFirstFile("public/{$cleanPath}");
|
||||
if (!$filePath) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,16 @@ class ThemeEvents
|
||||
*/
|
||||
const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth';
|
||||
|
||||
|
||||
/**
|
||||
* Theme register views event.
|
||||
* Called by the theme system when a theme is active, so that custom view templates can be registered
|
||||
* to be rendered in addition to existing app views.
|
||||
*
|
||||
* @param \BookStack\Theming\ThemeViews $themeViews
|
||||
*/
|
||||
const THEME_REGISTER_VIEWS = 'theme_register_views';
|
||||
|
||||
/**
|
||||
* Web before middleware action.
|
||||
* Runs before the request is handled but after all other middleware apart from those
|
||||
|
||||
59
app/Theming/ThemeModule.php
Normal file
59
app/Theming/ThemeModule.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
readonly class ThemeModule
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $description,
|
||||
public string $version,
|
||||
public string $folderName,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ThemeModule instance from JSON data.
|
||||
*
|
||||
* @throws ThemeModuleException
|
||||
*/
|
||||
public static function fromJson(array $data, string $folderName): self
|
||||
{
|
||||
if (empty($data['name']) || !is_string($data['name'])) {
|
||||
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'name' property");
|
||||
}
|
||||
|
||||
if (!isset($data['description']) || !is_string($data['description'])) {
|
||||
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'description' property");
|
||||
}
|
||||
|
||||
if (!isset($data['version']) || !is_string($data['version'])) {
|
||||
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
|
||||
}
|
||||
|
||||
if (!preg_match('/^v?\d+\.\d+\.\d+(-.*)?$/', $data['version'])) {
|
||||
throw new ThemeModuleException("Module in folder \"{$folderName}\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'");
|
||||
}
|
||||
|
||||
return new self(
|
||||
name: $data['name'],
|
||||
description: $data['description'],
|
||||
version: $data['version'],
|
||||
folderName: $folderName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a path for a file within this module.
|
||||
*/
|
||||
public function path($path = ''): string
|
||||
{
|
||||
$component = trim($path, '/');
|
||||
return theme_path("modules/{$this->folderName}/{$component}");
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return str_starts_with($this->version, 'v') ? $this->version : 'v' . $this->version;
|
||||
}
|
||||
}
|
||||
7
app/Theming/ThemeModuleException.php
Normal file
7
app/Theming/ThemeModuleException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
class ThemeModuleException extends \Exception
|
||||
{
|
||||
}
|
||||
133
app/Theming/ThemeModuleManager.php
Normal file
133
app/Theming/ThemeModuleManager.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ThemeModuleManager
|
||||
{
|
||||
/** @var array<string, ThemeModule>|null */
|
||||
protected array|null $loadedModules = null;
|
||||
|
||||
public function __construct(
|
||||
protected string $modulesFolderPath
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ThemeModule>
|
||||
*/
|
||||
public function getByName(string $name): array
|
||||
{
|
||||
return array_filter($this->load(), fn(ThemeModule $module) => $module->name === $name);
|
||||
}
|
||||
|
||||
public function deleteModuleFolder(string $moduleFolderName): void
|
||||
{
|
||||
$modules = $this->load();
|
||||
$module = $modules[$moduleFolderName] ?? null;
|
||||
if (!$module) {
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleFolderPath = $module->path('');
|
||||
if (!file_exists($moduleFolderPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteDirectoryRecursively($moduleFolderPath);
|
||||
unset($this->loadedModules[$moduleFolderName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ThemeModuleException
|
||||
*/
|
||||
public function addFromZip(string $name, ThemeModuleZip $zip): ThemeModule
|
||||
{
|
||||
$baseFolderName = Str::limit(Str::slug($name), 20);
|
||||
$folderName = $baseFolderName;
|
||||
while (!$baseFolderName || file_exists($this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName)) {
|
||||
$folderName = ($baseFolderName ?: 'mod') . '-' . Str::random(4);
|
||||
}
|
||||
|
||||
$folderPath = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName;
|
||||
$zip->extractTo($folderPath);
|
||||
|
||||
$module = $this->loadFromFolder($folderName);
|
||||
if (!$module) {
|
||||
throw new ThemeModuleException("Failed to load module from zip file after extraction");
|
||||
}
|
||||
|
||||
return $module;
|
||||
}
|
||||
|
||||
protected function deleteDirectoryRecursively(string $path): void
|
||||
{
|
||||
$items = array_diff(scandir($path), ['.', '..']);
|
||||
foreach ($items as $item) {
|
||||
$itemPath = $path . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_dir($itemPath)) {
|
||||
$this->deleteDirectoryRecursively($itemPath);
|
||||
} else {
|
||||
$deleted = unlink($itemPath);
|
||||
if (!$deleted) {
|
||||
throw new ThemeModuleException("Failed to delete file at \"{$itemPath}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
rmdir($path);
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
if ($this->loadedModules !== null) {
|
||||
return $this->loadedModules;
|
||||
}
|
||||
|
||||
if (!is_dir($this->modulesFolderPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subFolders = array_filter(scandir($this->modulesFolderPath), function ($item) {
|
||||
return $item !== '.' && $item !== '..' && is_dir($this->modulesFolderPath . DIRECTORY_SEPARATOR . $item);
|
||||
});
|
||||
|
||||
$modules = [];
|
||||
|
||||
foreach ($subFolders as $folderName) {
|
||||
$module = $this->loadFromFolder($folderName);
|
||||
if ($module) {
|
||||
$modules[$folderName] = $module;
|
||||
}
|
||||
}
|
||||
|
||||
$this->loadedModules = $modules;
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
protected function loadFromFolder(string $folderName): ThemeModule|null
|
||||
{
|
||||
$moduleJsonFile = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR . 'bookstack-module.json';
|
||||
if (!file_exists($moduleJsonFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$jsonContent = file_get_contents($moduleJsonFile);
|
||||
$jsonData = json_decode($jsonContent, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new ThemeModuleException("Invalid JSON in module file at \"{$moduleJsonFile}\": " . json_last_error_msg());
|
||||
}
|
||||
|
||||
$module = ThemeModule::fromJson($jsonData, $folderName);
|
||||
} catch (ThemeModuleException $exception) {
|
||||
throw $exception;
|
||||
} catch (\Exception $exception) {
|
||||
throw new ThemeModuleException("Failed loading module from \"{$moduleJsonFile}\" with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
98
app/Theming/ThemeModuleZip.php
Normal file
98
app/Theming/ThemeModuleZip.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use ZipArchive;
|
||||
|
||||
readonly class ThemeModuleZip
|
||||
{
|
||||
public function __construct(
|
||||
protected string $path
|
||||
) {
|
||||
}
|
||||
|
||||
public function extractTo(string $destinationPath): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($this->path);
|
||||
$zip->extractTo($destinationPath);
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the module's JSON metadata to read it into a ThemeModule instance.
|
||||
* @throws ThemeModuleException
|
||||
*/
|
||||
public function getModuleInstance(): ThemeModule
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$open = $zip->open($this->path);
|
||||
if ($open !== true) {
|
||||
throw new ThemeModuleException("Unable to open zip file at {$this->path}");
|
||||
}
|
||||
|
||||
$moduleJsonText = $zip->getFromName('bookstack-module.json');
|
||||
$zip->close();
|
||||
|
||||
if ($moduleJsonText === false) {
|
||||
throw new ThemeModuleException("bookstack-module.json not found within module ZIP at {$this->path}");
|
||||
}
|
||||
|
||||
$moduleJson = json_decode($moduleJsonText, true);
|
||||
if ($moduleJson === null) {
|
||||
throw new ThemeModuleException("Could not read JSON from bookstack-module.json within module ZIP at {$this->path}");
|
||||
}
|
||||
|
||||
return ThemeModule::fromJson($moduleJson, '_temp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the zip file.
|
||||
*/
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the zip file exists and that it appears to be a valid zip file.
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
if (!file_exists($this->path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$open = $zip->open($this->path, ZipArchive::RDONLY);
|
||||
if ($open === true) {
|
||||
$zip->close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total size of the zip file contents when uncompressed.
|
||||
*/
|
||||
public function getContentsSize(): int
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($this->path) !== true) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalSize = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if ($stat !== false) {
|
||||
$totalSize += $stat['size'];
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $totalSize;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\ThemeException;
|
||||
use Illuminate\Console\Application;
|
||||
use Illuminate\Console\Application as Artisan;
|
||||
use Illuminate\View\FileViewFinder;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class ThemeService
|
||||
@@ -15,6 +16,11 @@ class ThemeService
|
||||
*/
|
||||
protected array $listeners = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ThemeModule>
|
||||
*/
|
||||
protected array $modules = [];
|
||||
|
||||
/**
|
||||
* Get the currently configured theme.
|
||||
* Returns an empty string if not configured.
|
||||
@@ -76,20 +82,71 @@ class ThemeService
|
||||
}
|
||||
|
||||
/**
|
||||
* Read any actions from the set theme path if the 'functions.php' file exists.
|
||||
* Read any actions from the 'functions.php' file of the active theme or its modules.
|
||||
*/
|
||||
public function readThemeActions(): void
|
||||
{
|
||||
$themeActionsFile = theme_path('functions.php');
|
||||
if ($themeActionsFile && file_exists($themeActionsFile)) {
|
||||
$moduleFunctionFiles = array_map(function (ThemeModule $module): string {
|
||||
return $module->path('functions.php');
|
||||
}, $this->modules);
|
||||
$allFunctionFiles = array_merge(array_values($moduleFunctionFiles), [theme_path('functions.php')]);
|
||||
$filteredFunctionFiles = array_filter($allFunctionFiles, function (string $file): bool {
|
||||
return $file && file_exists($file);
|
||||
});
|
||||
|
||||
foreach ($filteredFunctionFiles as $functionFile) {
|
||||
try {
|
||||
require $themeActionsFile;
|
||||
require $functionFile;
|
||||
} catch (\Error $exception) {
|
||||
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
|
||||
throw new ThemeException("Failed loading theme functions file at \"{$functionFile}\" with error: {$exception->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the modules folder and load in any valid theme modules.
|
||||
* @throws ThemeModuleException
|
||||
*/
|
||||
public function loadModules(): void
|
||||
{
|
||||
$modulesFolder = theme_path('modules');
|
||||
if (!$modulesFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->modules = (new ThemeModuleManager($modulesFolder))->load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded theme modules.
|
||||
* @return array<string, ThemeModule>
|
||||
*/
|
||||
public function getModules(): array
|
||||
{
|
||||
return $this->modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for a specific file within the theme or its modules.
|
||||
* Returns the first file found or null if not found.
|
||||
*/
|
||||
public function findFirstFile(string $path): ?string
|
||||
{
|
||||
$themePath = theme_path($path);
|
||||
if (file_exists($themePath)) {
|
||||
return $themePath;
|
||||
}
|
||||
|
||||
foreach ($this->modules as $module) {
|
||||
$customizedFile = $module->path($path);
|
||||
if (file_exists($customizedFile)) {
|
||||
return $customizedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see SocialDriverManager::addSocialDriver
|
||||
*/
|
||||
|
||||
104
app/Theming/ThemeViews.php
Normal file
104
app/Theming/ThemeViews.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use BookStack\Exceptions\ThemeException;
|
||||
use Illuminate\View\FileViewFinder;
|
||||
|
||||
class ThemeViews
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
protected array $beforeViews = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
protected array $afterViews = [];
|
||||
|
||||
/**
|
||||
* Register any extra paths for where we may expect views to be located
|
||||
* with the provided FileViewFinder, to make custom views available for use.
|
||||
* @param ThemeModule[] $modules
|
||||
*/
|
||||
public function registerViewPathsForTheme(FileViewFinder $finder, array $modules): void
|
||||
{
|
||||
foreach ($modules as $module) {
|
||||
$moduleViewsPath = $module->path('views');
|
||||
if (file_exists($moduleViewsPath) && is_dir($moduleViewsPath)) {
|
||||
$finder->prependLocation($moduleViewsPath);
|
||||
}
|
||||
}
|
||||
|
||||
$finder->prependLocation(theme_path());
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the response for a blade template view include.
|
||||
*/
|
||||
public function handleViewInclude(string $viewPath, array $data = []): string
|
||||
{
|
||||
if (!$this->hasRegisteredViews()) {
|
||||
return view()->make($viewPath, $data)->render();
|
||||
}
|
||||
|
||||
$viewsContent = [
|
||||
...$this->renderViewSets($this->beforeViews[$viewPath] ?? [], $data),
|
||||
view()->make($viewPath, $data)->render(),
|
||||
...$this->renderViewSets($this->afterViews[$viewPath] ?? [], $data),
|
||||
];
|
||||
|
||||
return implode("\n", $viewsContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom view to be rendered before the given target view is included in the template system.
|
||||
*/
|
||||
public function renderBefore(string $targetView, string $localView, int $priority = 50): void
|
||||
{
|
||||
$this->registerAdjacentView($this->beforeViews, $targetView, $localView, $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom view to be rendered after the given target view is included in the template system.
|
||||
*/
|
||||
public function renderAfter(string $targetView, string $localView, int $priority = 50): void
|
||||
{
|
||||
$this->registerAdjacentView($this->afterViews, $targetView, $localView, $priority);
|
||||
}
|
||||
|
||||
public function hasRegisteredViews(): bool
|
||||
{
|
||||
return !empty($this->beforeViews) && !empty($this->afterViews);
|
||||
}
|
||||
|
||||
protected function registerAdjacentView(array &$location, string $targetView, string $localView, int $priority = 50): void
|
||||
{
|
||||
$viewPath = theme_path($localView . '.blade.php');
|
||||
if (!file_exists($viewPath)) {
|
||||
throw new ThemeException("Expected registered view file at \"{$viewPath}\" does not exist");
|
||||
}
|
||||
|
||||
if (!isset($location[$targetView])) {
|
||||
$location[$targetView] = [];
|
||||
}
|
||||
$location[$targetView][$viewPath] = $priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $viewSet
|
||||
* @return string[]
|
||||
*/
|
||||
protected function renderViewSets(array $viewSet, array $data): array
|
||||
{
|
||||
$paths = array_keys($viewSet);
|
||||
usort($paths, function (string $a, string $b) use ($viewSet) {
|
||||
return $viewSet[$a] <=> $viewSet[$b];
|
||||
});
|
||||
|
||||
return array_map(function (string $viewPath) use ($data) {
|
||||
return view()->file($viewPath, $data)->render();
|
||||
}, $paths);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Translation;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use Illuminate\Translation\FileLoader as BaseLoader;
|
||||
|
||||
class FileLoader extends BaseLoader
|
||||
@@ -12,11 +13,6 @@ class FileLoader extends BaseLoader
|
||||
* Extends Laravel's translation FileLoader to look in multiple directories
|
||||
* so that we can load in translation overrides from the theme file if wanted.
|
||||
*
|
||||
* Note: As of using Laravel 10, this may now be redundant since Laravel's
|
||||
* file loader supports multiple paths. This needs further testing though
|
||||
* to confirm if Laravel works how we expect, since we specifically need
|
||||
* the theme folder to be able to partially override core lang files.
|
||||
*
|
||||
* @param string $locale
|
||||
* @param string $group
|
||||
* @param string|null $namespace
|
||||
@@ -32,9 +28,18 @@ class FileLoader extends BaseLoader
|
||||
if (is_null($namespace) || $namespace === '*') {
|
||||
$themePath = theme_path('lang');
|
||||
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
|
||||
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
|
||||
|
||||
return array_merge($originalTranslations, $themeTranslations);
|
||||
$modules = Theme::getModules();
|
||||
$moduleTranslations = [];
|
||||
foreach ($modules as $module) {
|
||||
$modulePath = $module->path('lang');
|
||||
if (file_exists($modulePath)) {
|
||||
$moduleTranslations = array_merge($moduleTranslations, $this->loadPaths([$modulePath], $locale, $group));
|
||||
}
|
||||
}
|
||||
|
||||
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
|
||||
return array_merge($originalTranslations, $moduleTranslations, $themeTranslations);
|
||||
}
|
||||
|
||||
return $this->loadNamespaced($locale, $group, $namespace);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
|
||||
class SvgIcon
|
||||
{
|
||||
public function __construct(
|
||||
@@ -23,12 +25,9 @@ class SvgIcon
|
||||
$attrString .= $attrName . '="' . $attr . '" ';
|
||||
}
|
||||
|
||||
$iconPath = resource_path('icons/' . $this->name . '.svg');
|
||||
$themeIconPath = theme_path('icons/' . $this->name . '.svg');
|
||||
|
||||
if ($themeIconPath && file_exists($themeIconPath)) {
|
||||
$iconPath = $themeIconPath;
|
||||
} elseif (!file_exists($iconPath)) {
|
||||
$defaultIconPath = resource_path('icons/' . $this->name . '.svg');
|
||||
$iconPath = Theme::findFirstFile("icons/{$this->name}.svg") ?? $defaultIconPath;
|
||||
if (!file_exists($iconPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
485
composer.lock
generated
485
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// TODO - Handle compatibility with older databases that don't support vectors
|
||||
Schema::create('search_vectors', function (Blueprint $table) {
|
||||
$table->string('entity_type', 100);
|
||||
$table->integer('entity_id');
|
||||
$table->text('text');
|
||||
|
||||
$table->index(['entity_type', 'entity_id']);
|
||||
});
|
||||
|
||||
$table = DB::getTablePrefix() . 'search_vectors';
|
||||
|
||||
// TODO - Vector size might need to be dynamic
|
||||
DB::statement("ALTER TABLE {$table} ADD COLUMN (embedding VECTOR(1536) NOT NULL)");
|
||||
DB::statement("ALTER TABLE {$table} ADD VECTOR INDEX (embedding) DISTANCE=cosine");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('search_vectors');
|
||||
}
|
||||
};
|
||||
@@ -99,6 +99,41 @@ Theme::listen(ThemeEvents::APP_BOOT, function($app) {
|
||||
});
|
||||
```
|
||||
|
||||
## Custom View Registration Example
|
||||
|
||||
Using the logical theme system, you can register custom views to be rendered before/after other existing views, providing a flexible way to add content without needing to override and/or replicate existing content. This is done by listening to the `THEME_REGISTER_VIEWS`.
|
||||
|
||||
**Note:** You don't need to use this to override existing views, or register whole new main views to use, since that's done automatically based on their existence. This is just for advanced capabilities like inserting before/after existing views.
|
||||
|
||||
This event provides a `ThemeViews` instance which has the following methods made available:
|
||||
|
||||
- `renderBefore(string $targetView, string $localView, int $priority)`
|
||||
- `renderAfter(string $targetView, string $localView, int $priority)`
|
||||
|
||||
The target view is the name of that which we want to insert our custom view relative to.
|
||||
The local view is the name of the view we want to add and render.
|
||||
The priority provides a suggestion to the ordering of view display, with lower numbers being shown first. This defaults to 50 if not provided.
|
||||
|
||||
Here's an example of this in use:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Theming\ThemeViews;
|
||||
|
||||
Theme::listen(ThemeEvents::THEME_REGISTER_VIEWS, function (ThemeViews $themeViews) {
|
||||
$themeViews->renderBefore('layouts.parts.header', 'welcome-banner', 4);
|
||||
$themeViews->renderAfter('layouts.parts.header', 'information-alert');
|
||||
$themeViews->renderAfter('layouts.parts.header', 'additions.password-notice', 20);
|
||||
});
|
||||
```
|
||||
|
||||
In this example, we're inserting custom views before and after the main header bar.
|
||||
BookStack will look for a `welcome-banner.blade.php` file within our theme folder (or a theme module view folder) to render before the header. It'll look for the `information-alert.blade.php` and `additions/password-notice.blade.php` views to render afterwards.
|
||||
The password notice will be shown above the information alert view, since it has a specified priority of 20, whereas the information alert view would default to a priority of 50.
|
||||
|
||||
## Custom Command Registration Example
|
||||
|
||||
The logical theme system supports adding custom [artisan commands](https://laravel.com/docs/8.x/artisan) to BookStack.
|
||||
|
||||
68
dev/docs/theme-system-modules.md
Normal file
68
dev/docs/theme-system-modules.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Theme System Modules
|
||||
|
||||
A theme system module is a collection of customizations using the [visual](visual-theme-system.md) and [logical](logical-theme-system.md) theme systems, provided along with some metadata, that can be installed alongside other modules within a theme. They can effectively be thought of as "plugins" or "extensions" that can be applied in addition to any customizations in the active theme.
|
||||
|
||||
### Module Location
|
||||
|
||||
Modules are contained within a folder themselves, which should be located inside a `modules` folder within a [BookStack theme folder](visual-theme-system.md#getting-started).
|
||||
As an example, starting from the `themes/` top-level folder of a BookStack instance:
|
||||
|
||||
```txt
|
||||
themes
|
||||
└── my-theme
|
||||
└── modules
|
||||
├── module-a
|
||||
│ └── bookstack-module.json
|
||||
└── module-b
|
||||
└── bookstack-module.json
|
||||
```
|
||||
|
||||
### Module Format
|
||||
|
||||
A module exists as a folder in the location [as detailed above](#module-location).
|
||||
The content within the module folder should then follow this format:
|
||||
|
||||
- `bookstack-module.json` - REQUIRED - A JSON file containing [the metadata](#module-json-metadata) for the module.
|
||||
- `functions.php` - OPTIONAL - A PHP file containing code for the [logical theme system](logical-theme-system.md).
|
||||
- `icons/` - OPTIONAL - A folder containing any icons to use as per [the visual theme system](visual-theme-system.md#customizing-icons).
|
||||
- `lang/` - OPTIONAL - A folder containing any language files to use as per [the visual theme system](visual-theme-system.md#customizing-text-content).
|
||||
- `public/` - OPTIONAL - A folder containing any files to expose into public web-space as per [the visual theme system](visual-theme-system.md#publicly-accessible-files).
|
||||
- `views/` - OPTIONAL - A folder containing any view additions or overrides as per [the visual theme system](visual-theme-system.md#customizing-view-files).
|
||||
|
||||
You can create additional directories/files for your own needs within the module, but ideally name them something unique to prevent conflicts with the above structure.
|
||||
|
||||
### Module JSON Metadata
|
||||
|
||||
Modules are required to have a `bookstack-module.json` file in the top level directory of the module.
|
||||
This must be a JSON file with the following properties:
|
||||
|
||||
- `name` - string - An (ideally unique) name for the module.
|
||||
- `description` - string - A short description of the module.
|
||||
- `version` - string - A string version number generally following [semantic versioning](https://semver.org/).
|
||||
- Examples: `v0.4.0`, `4.3.12`, `v0.1.0-beta4`.
|
||||
|
||||
### Customization Order/Precedence
|
||||
|
||||
It's possible that multiple modules may override/customize the same content.
|
||||
Right now, there's no assurance in regard to the order in which modules may be loaded.
|
||||
Generally they will be used/searched in order of their module folder name, but this is not assured and should not be relied upon.
|
||||
|
||||
It's also possible that modules customize the same content as the configured theme.
|
||||
In this scenario, the theme takes precedence. Modules are designed to be more portable and instance abstract, whereas the theme folder would typically be specific to the instance.
|
||||
This allows the theme to be used to customize or override module content for the BookStack instance, without altering the module code itself.
|
||||
|
||||
### Module Best Practices
|
||||
|
||||
Here are some general best practices when it comes to creating modules:
|
||||
|
||||
- Use a unique name and clear description so the user can understand the purpose of the module.
|
||||
- Increment the metadata version on change, keeping to [semver](https://semver.org/) to indicate compatibility of new versions.
|
||||
- Where possible, prefer to [insert views before/after](logical-theme-system.md#custom-view-registration-example) instead of overriding existing views, to reduce likelihood of conflicts or update troubles.
|
||||
|
||||
### Distribution Format
|
||||
|
||||
Modules are expected to be distributed as a compressed ZIP file, where the ZIP contents follow that of a module folder.
|
||||
BookStack provides a `php artisan bookstack:install-module` command which allows modules to be installed from these ZIP files, either from a local path or from a web URL.
|
||||
Currently, there's a hardcoded total filesize limit of 50MB for module contents installed via this method.
|
||||
|
||||
There is not yet any direct update mechanism for modules, although this is something we may introduce in the future.
|
||||
@@ -4,7 +4,7 @@ BookStack allows visual customization via the theme system which enables you to
|
||||
|
||||
This is part of the theme system alongside the [logical theme system](./logical-theme-system.md).
|
||||
|
||||
**Note:** This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates.
|
||||
**Note:** This theme system itself is maintained and supported, but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -18,6 +18,9 @@ You'll need to tell BookStack to use your theme via the `APP_THEME` option in yo
|
||||
Content placed in your `themes/<theme_name>/` folder will override the original view files found in the `resources/views` folder. These files are typically [Laravel Blade](https://laravel.com/docs/10.x/blade) files.
|
||||
As an example, I could override the `resources/views/books/parts/list-item.blade.php` file with my own template at the path `themes/<theme_name>/books/parts/list-item.blade.php`.
|
||||
|
||||
In addition to overriding original views, this could be used to add new views for use via the [logical theme system](logical-theme-system.md).
|
||||
By using the `THEME_REGISTER_VIEWS` logical event, you can also register your views to be rendered before/after existing views. An example of this can be found in our [logical theme guidance](logical-theme-system.md#custom-view-registration-example).
|
||||
|
||||
## Customizing Icons
|
||||
|
||||
SVG files placed in a `themes/<theme_name>/icons` folder will override any icons of the same name within `resources/icons`. You'd typically want to follow the format convention of the existing icons, where no XML deceleration is included and no width & height attributes are set, to ensure optimal compatibility.
|
||||
@@ -50,7 +53,7 @@ configured application theme.
|
||||
|
||||
There are some considerations to these publicly served files:
|
||||
|
||||
- Only a predetermined range "web safe" content-types are currently served.
|
||||
- Only a predetermined range of "web safe" content-types are currently served.
|
||||
- This limits running into potential insecure scenarios in serving problematic file types.
|
||||
- A static 1-day cache time it set on files served from this folder.
|
||||
- You can use alternative cache-breaking techniques (change of query string) upon changes if needed.
|
||||
|
||||
@@ -760,6 +760,13 @@ Copyright: Copyright (c) 2014-present Fabien Potencier
|
||||
Source: https://github.com/symfony/var-dumper.git
|
||||
Link: https://symfony.com
|
||||
-----------
|
||||
thecodingmachine/safe
|
||||
License: MIT
|
||||
License File: vendor/thecodingmachine/safe/LICENSE
|
||||
Copyright: Copyright (c) 2018 TheCodingMachine
|
||||
Source: https://github.com/thecodingmachine/safe.git
|
||||
Link: https://github.com/thecodingmachine/safe.git
|
||||
-----------
|
||||
tijsverkoyen/css-to-inline-styles
|
||||
License: BSD-3-Clause
|
||||
License File: vendor/tijsverkoyen/css-to-inline-styles/LICENSE.md
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
return [
|
||||
|
||||
'failed' => 'Въведените удостоверителни данни не съвпадат с нашите записи.',
|
||||
'failed' => 'Въведените данни не съвпадат с информацията в системата.',
|
||||
'throttle' => 'Твърде много опити за влизане. Опитайте пак след :seconds секунди.',
|
||||
|
||||
// Login & Register
|
||||
@@ -65,7 +65,7 @@ return [
|
||||
'email_confirm_thanks_desc' => 'Почакайте малко, обработвайки потвърждението ви. Ако не сте пренасочени след 3 секунди, то натиснете долу връзката "Продължаване", за да продължите.',
|
||||
|
||||
'email_not_confirmed' => 'Имейл адресът не е потвърден',
|
||||
'email_not_confirmed_text' => 'Вашият емейл адрес все още не е потвърден.',
|
||||
'email_not_confirmed_text' => 'Вашият имейл адрес все още не е потвърден.',
|
||||
'email_not_confirmed_click_link' => 'Моля да последвате линка, който ви беше изпратен непосредствено след регистрацията.',
|
||||
'email_not_confirmed_resend' => 'Ако не откривате писмото, може да го изпратите отново като попълните формуляра по-долу.',
|
||||
'email_not_confirmed_resend_button' => 'Изпрати отново емейла за потвърждение',
|
||||
@@ -91,7 +91,7 @@ return [
|
||||
'mfa_option_totp_title' => 'Мобилно приложение',
|
||||
'mfa_option_totp_desc' => 'За да използваш многофакторно удостоверяване, ще ти трябва мобилно приложение, което поддържа временни еднократни пароли (TOTP), като например Google Authenticator, Authy или Microsoft Authenticator.',
|
||||
'mfa_option_backup_codes_title' => 'Резервни кодове',
|
||||
'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.',
|
||||
'mfa_option_backup_codes_desc' => 'Генерира набор от еднократни резервни кодове, които ще въвеждате при влизане, за да потвърдите самоличността си. Уверете се, че ги съхранявате на безопасно и сигурно място.',
|
||||
'mfa_gen_confirm_and_enable' => 'Потвърди и включи',
|
||||
'mfa_gen_backup_codes_title' => 'Настройка на резервни кодове',
|
||||
'mfa_gen_backup_codes_desc' => 'Запази този лист с кодове на сигурно място. Когато достъпваш системата, ще можеш да използваш един от тези кодове като вторичен механизъм за удостоверяване.',
|
||||
|
||||
@@ -6,7 +6,7 @@ return [
|
||||
|
||||
// Buttons
|
||||
'cancel' => 'Отказ',
|
||||
'close' => 'Close',
|
||||
'close' => 'Затвори',
|
||||
'confirm' => 'Потвърждаване',
|
||||
'back' => 'Назад',
|
||||
'save' => 'Запис',
|
||||
@@ -20,7 +20,7 @@ return [
|
||||
'description' => 'Описание',
|
||||
'role' => 'Роля',
|
||||
'cover_image' => 'Образ на корицата',
|
||||
'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.',
|
||||
'cover_image_description' => 'Изображението трябва да е около 440x250 px. Тъй като ще се мащабира и изрязва автоматично спрямо нуждите на интерфейса, крайните размери при показване може да се различават.',
|
||||
|
||||
// Actions
|
||||
'actions' => 'Действия',
|
||||
@@ -30,8 +30,8 @@ return [
|
||||
'create' => 'Създаване',
|
||||
'update' => 'Обновяване',
|
||||
'edit' => 'Редактиране',
|
||||
'archive' => 'Archive',
|
||||
'unarchive' => 'Un-Archive',
|
||||
'archive' => 'Архивирай',
|
||||
'unarchive' => 'Разархивирай',
|
||||
'sort' => 'Сортиране',
|
||||
'move' => 'Преместване',
|
||||
'copy' => 'Копиране',
|
||||
@@ -44,7 +44,7 @@ return [
|
||||
'remove' => 'Премахване',
|
||||
'add' => 'Добавяне',
|
||||
'configure' => 'Конфигуриране',
|
||||
'manage' => 'Manage',
|
||||
'manage' => 'Управлявай',
|
||||
'fullscreen' => 'Цял екран',
|
||||
'favourite' => 'Любимо',
|
||||
'unfavourite' => 'Не е любимо',
|
||||
@@ -54,7 +54,7 @@ return [
|
||||
'filter_clear' => 'Изчистване на филтрите',
|
||||
'download' => 'Изтегляне',
|
||||
'open_in_tab' => 'Отваряне в раздел',
|
||||
'open' => 'Open',
|
||||
'open' => 'Отвори',
|
||||
|
||||
// Sort Options
|
||||
'sort_options' => 'Опции за сортиране',
|
||||
@@ -111,5 +111,5 @@ return [
|
||||
'terms_of_service' => 'Условия на услугата',
|
||||
|
||||
// OpenSearch
|
||||
'opensearch_description' => 'Search :appName',
|
||||
'opensearch_description' => 'Търси :appName',
|
||||
];
|
||||
|
||||
@@ -13,7 +13,7 @@ return [
|
||||
'cancel' => 'Отказ',
|
||||
'save' => 'Запис',
|
||||
'close' => 'Затваряне',
|
||||
'apply' => 'Apply',
|
||||
'apply' => 'Приложи',
|
||||
'undo' => 'Отмяна',
|
||||
'redo' => 'Повтаряне',
|
||||
'left' => 'Вляво',
|
||||
|
||||
@@ -10,7 +10,7 @@ return [
|
||||
|
||||
// Auth
|
||||
'error_user_exists_different_creds' => 'Потребител с емайл :email вече съществува но с други данни.',
|
||||
'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details',
|
||||
'auth_pre_register_theme_prevention' => 'Потребителски профил не може да бъде създаден с посочената информация',
|
||||
'email_already_confirmed' => 'Емейлът вече беше потвърден. Моля опитрайте да влезете.',
|
||||
'email_confirmation_invalid' => 'Този код за достъп не е валиден или вече е бил използван, Моля опитай да се регистрираш отново.',
|
||||
'email_confirmation_expired' => 'Кодът за потвърждение изтече, нов емейл за потвърждение беше изпратен.',
|
||||
@@ -37,7 +37,7 @@ return [
|
||||
'social_driver_not_found' => 'Кодът за връзка със социалната мрежа не съществува',
|
||||
'social_driver_not_configured' => 'Социалните настройки на твоя :socialAccount не са конфигурирани правилно.',
|
||||
'invite_token_expired' => 'Твоята покана е изтекла. Вместо това може да пробваш да възстановиш паролата на профила си.',
|
||||
'login_user_not_found' => 'A user for this action could not be found.',
|
||||
'login_user_not_found' => 'Потребител за това действие не може да бъде намерено.',
|
||||
|
||||
// System
|
||||
'path_not_writable' => 'Не може да се качи файл в :filePath. Увери се на сървъра, че в пътя може да се записва.',
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'Nelze načíst ZIP soubor.',
|
||||
'import_zip_cant_decode_data' => 'Nelze najít a dekódovat data.json v archivu ZIP.',
|
||||
'import_zip_no_data' => 'ZIP archiv neobsahuje knihy, kapitoly nebo stránky.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'Obsah souboru data.json v archivu ZIP překračuje maximální povolenou velikost.',
|
||||
'import_validation_failed' => 'Importování ZIP selhalo s chybami:',
|
||||
'import_zip_failed_notification' => 'Nepodařilo se naimportovat ZIP soubor.',
|
||||
'import_perms_books' => 'Chybí vám požadovaná oprávnění k vytvoření knih.',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'Nahrávání :attribute se nezdařilo.',
|
||||
|
||||
'zip_file' => ':attribute musí odkazovat na soubor v archivu ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'Soubor :attribute nesmí překročit :size MB.',
|
||||
'zip_file_mime' => ':attribute musí odkazovat na soubor typu :validTypes, nalezen :foundType.',
|
||||
'zip_model_expected' => 'Očekáván datový objekt, ale nalezen „:type“.',
|
||||
'zip_unique' => ':attribute musí být jedinečný pro typ objektu v archivu ZIP.',
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.',
|
||||
'import_zip_cant_decode_data' => 'No se pudo encontrar y decodificar el archivo data.json. en el archivo ZIP.',
|
||||
'import_zip_no_data' => 'Los datos del archivo ZIP no contienen ningún libro, capítulo o contenido de página.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'El contenido del ZIP data.json excede el tamaño máximo de carga configurado.',
|
||||
'import_validation_failed' => 'Error al validar la importación del ZIP con errores:',
|
||||
'import_zip_failed_notification' => 'Error al importar archivo ZIP.',
|
||||
'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'El archivo no ha podido subirse. Es posible que el servidor no acepte archivos de este tamaño.',
|
||||
|
||||
'zip_file' => 'El :attribute necesita hacer referencia a un archivo dentro del ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'El archivo :attribute no debe exceder :size MB.',
|
||||
'zip_file_mime' => 'El :attribute necesita hacer referencia a un archivo de tipo :validTypes, encontrado :foundType.',
|
||||
'zip_model_expected' => 'Se esperaba un objeto de datos, pero se encontró ":type".',
|
||||
'zip_unique' => 'El :attribute debe ser único para el tipo de objeto dentro del ZIP.',
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.',
|
||||
'import_zip_cant_decode_data' => 'No se pudo encontrar ni decodificar el contenido del archivo ZIP data.json.',
|
||||
'import_zip_no_data' => 'Los datos del archivo ZIP no tienen un libro, un capítulo o contenido de página en su contenido.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'El contenido del ZIP data.json excede el tamaño máximo de carga configurado.',
|
||||
'import_validation_failed' => 'Error al validar la importación del ZIP con los errores:',
|
||||
'import_zip_failed_notification' => 'Error al importar archivo ZIP.',
|
||||
'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'El archivo no se pudo subir. Puede ser que el servidor no acepte archivos de este tamaño.',
|
||||
|
||||
'zip_file' => 'El :attribute necesita hacer referencia a un archivo dentro del ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'El archivo :attribute no debe exceder :size MB.',
|
||||
'zip_file_mime' => 'El :attribute necesita hacer referencia a un archivo de tipo :validTypes, encontrado :foundType.',
|
||||
'zip_model_expected' => 'Se esperaba un objeto de datos, pero se encontró ":type".',
|
||||
'zip_unique' => 'El :attribute debe ser único para el tipo de objeto dentro del ZIP.',
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'ZIP-faili lugemine ebaõnnestus.',
|
||||
'import_zip_cant_decode_data' => 'ZIP-failist ei leitud data.json sisu.',
|
||||
'import_zip_no_data' => 'ZIP-failist ei leitud raamatute, peatükkide või lehtede sisu.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'ZIP-faili data.json sisu ületab rakenduses seadistatud maksimaalse failisuuruse.',
|
||||
'import_validation_failed' => 'Imporditud ZIP-faili valideerimine ebaõnnestus vigadega:',
|
||||
'import_zip_failed_notification' => 'ZIP-faili importimine ebaõnnestus.',
|
||||
'import_perms_books' => 'Sul puuduvad õigused raamatute lisamiseks.',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'Faili üleslaadimine ebaõnnestus. Server ei pruugi sellise suurusega faile vastu võtta.',
|
||||
|
||||
'zip_file' => ':attribute peab viitama failile ZIP-arhiivi sees.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'Fail :attribute ei tohi olla suurem kui :size MB.',
|
||||
'zip_file_mime' => ':attribute peab viitama :validTypes tüüpi failile, leiti :foundType.',
|
||||
'zip_model_expected' => 'Oodatud andmete asemel leiti ":type".',
|
||||
'zip_unique' => ':attribute peab olema ZIP-arhiivi piires objekti tüübile unikaalne.',
|
||||
|
||||
@@ -54,7 +54,7 @@ return [
|
||||
'import_continue_desc' => 'Examinez le contenu à importer à partir du fichier ZIP téléchargé. Lorsque vous êtes prêt, lancez l\'importation pour ajouter son contenu à ce système. Le fichier d\'importation ZIP téléchargé sera automatiquement supprimé si l\'importation est réussie.',
|
||||
'import_details' => 'Détails de l\'importation',
|
||||
'import_run' => 'Exécuter Importation',
|
||||
'import_size' => ':size taille du ZIP d\'import',
|
||||
'import_size' => ':size Taille du fichier ZIP à importer',
|
||||
'import_uploaded_at' => ':relativeTime téléchargé',
|
||||
'import_uploaded_by' => 'Téléchargé par',
|
||||
'import_location' => 'Emplacement de l\'importation',
|
||||
@@ -330,13 +330,13 @@ return [
|
||||
|
||||
// Editor Sidebar
|
||||
'toggle_sidebar' => 'Afficher/masquer la barre latérale',
|
||||
'page_tags' => 'Mots-clés de la page',
|
||||
'chapter_tags' => 'Mots-clés du chapitre',
|
||||
'book_tags' => 'Mots-clés du livre',
|
||||
'shelf_tags' => 'Mots-clés de l\'étagère',
|
||||
'tag' => 'Mot-clé',
|
||||
'tags' => 'Mots-clés',
|
||||
'tags_index_desc' => 'Les tags peuvent être appliqués au contenu du système pour appliquer une forme flexible de catégorisation. Les tags peuvent avoir à la fois une clé et une valeur, la valeur étant facultative. Une fois appliqué, le contenu peut ensuite être interrogé à l’aide du nom et de la valeur du tag.',
|
||||
'page_tags' => 'Étiquettes de la page',
|
||||
'chapter_tags' => 'Étiquettes du chapitre',
|
||||
'book_tags' => 'Étiquettes du livre',
|
||||
'shelf_tags' => 'Étiquettes de l\'étagère',
|
||||
'tag' => 'Étiquette',
|
||||
'tags' => 'Étiquettes',
|
||||
'tags_index_desc' => 'Les étiquettes peuvent être mises sur le contenu pour appliquer une forme flexible de catégorisation. Les étiquettes peuvent avoir à la fois une clé et une valeur, la valeur étant facultative. Une fois appliqué, le contenu peut ensuite être interrogé à l’aide du nom et de la valeur de l’étiquette.',
|
||||
'tag_name' => 'Nom de l’étiquette',
|
||||
'tag_value' => 'Valeur du mot-clé (optionnel)',
|
||||
'tags_explain' => "Ajouter des mots-clés pour catégoriser votre contenu.",
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'Impossible de lire le fichier ZIP.',
|
||||
'import_zip_cant_decode_data' => 'Impossible de trouver et de décoder le contenu ZIP data.json.',
|
||||
'import_zip_no_data' => 'Les données du fichier ZIP n\'ont pas de livre, de chapitre ou de page attendus.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'Le contenu du fichier ZIP pour data.json dépasse la taille maximale de téléversement autorisée.',
|
||||
'import_validation_failed' => 'L\'importation du ZIP n\'a pas été validée avec les erreurs :',
|
||||
'import_zip_failed_notification' => 'Impossible d\'importer le fichier ZIP.',
|
||||
'import_perms_books' => 'Vous n\'avez pas les permissions requises pour créer des livres.',
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
*/
|
||||
return [
|
||||
|
||||
'new_comment_subject' => 'Nouveau commentaire sur la page: :pageName',
|
||||
'new_comment_subject' => 'Nouveau commentaire sur la page : :pageName',
|
||||
'new_comment_intro' => 'Un utilisateur a commenté une page dans :appName:',
|
||||
'new_page_subject' => 'Nouvelle page: :pageName',
|
||||
'new_page_intro' => 'Une nouvelle page a été créée dans :appName:',
|
||||
'updated_page_subject' => 'Page mise à jour: :pageName',
|
||||
'updated_page_intro' => 'Une page a été mise à jour dans :appName:',
|
||||
'updated_page_debounce' => 'Pour éviter de nombreuses notifications, pendant un certain temps, vous ne recevrez pas de notifications pour d\'autres modifications de cette page par le même éditeur.',
|
||||
'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName',
|
||||
'comment_mention_intro' => 'You were mentioned in a comment on :appName:',
|
||||
'comment_mention_subject' => 'Vous avez été mentionné dans un commentaire sur la page : :pageName',
|
||||
'comment_mention_intro' => 'Vous avez été mentionné dans un commentaire sur :appName:',
|
||||
|
||||
'detail_page_name' => 'Nom de la page :',
|
||||
'detail_page_path' => 'Chemin de la page :',
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
'notifications_desc' => 'Contrôlez les notifications par e-mail que vous recevez lorsque certaines activités sont effectuées dans le système.',
|
||||
'notifications_opt_own_page_changes' => 'Notifier lors des modifications des pages que je possède',
|
||||
'notifications_opt_own_page_comments' => 'Notifier lorsque les pages que je possède sont commentées',
|
||||
'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment',
|
||||
'notifications_opt_comment_mentions' => 'Notifier lorsque je suis mentionné dans un commentaire',
|
||||
'notifications_opt_comment_replies' => 'Notifier les réponses à mes commentaires',
|
||||
'notifications_save' => 'Enregistrer les préférences',
|
||||
'notifications_update_success' => 'Les préférences de notification ont été mises à jour !',
|
||||
|
||||
@@ -17,14 +17,14 @@ return [
|
||||
'app_features_security' => 'Fonctionnalités et sécurité',
|
||||
'app_name' => 'Nom de l\'application',
|
||||
'app_name_desc' => 'Ce nom est affiché dans l\'en-tête et les e-mails.',
|
||||
'app_name_header' => 'Afficher le nom dans l\'en-tête ?',
|
||||
'app_name_header' => 'Afficher le nom dans l\'en-tête',
|
||||
'app_public_access' => 'Accès public',
|
||||
'app_public_access_desc' => 'L\'activation de cette option permettra aux visiteurs, qui ne sont pas connectés, d\'accéder au contenu de votre instance BookStack.',
|
||||
'app_public_access_desc_guest' => 'L\'accès pour les visiteurs publics peut être contrôlé par l\'utilisateur "Guest".',
|
||||
'app_public_access_toggle' => 'Autoriser l\'accès public',
|
||||
'app_public_viewing' => 'Accepter l\'affichage public des pages ?',
|
||||
'app_secure_images' => 'Ajout d\'image sécurisé',
|
||||
'app_secure_images_toggle' => 'Activer l\'ajout d\'image sécurisé',
|
||||
'app_secure_images_toggle' => 'Activer l\'ajout d\'image sécurisée',
|
||||
'app_secure_images_desc' => 'Pour des questions de performances, toutes les images sont publiques. Cette option ajoute une chaîne aléatoire difficile à deviner dans les URLs des images.',
|
||||
'app_default_editor' => 'Éditeur de page par défaut',
|
||||
'app_default_editor_desc' => 'Sélectionnez l\'éditeur qui sera utilisé par défaut lors de l\'édition de nouvelles pages. Cela peut être remplacé au niveau de la page où les permissions sont autorisées.',
|
||||
@@ -75,8 +75,8 @@ return [
|
||||
'reg_confirm_restrict_domain_placeholder' => 'Aucune restriction en place',
|
||||
|
||||
// Sorting Settings
|
||||
'sorting' => 'Lists & Sorting',
|
||||
'sorting_book_default' => 'Default Book Sort Rule',
|
||||
'sorting' => 'Listes et tri',
|
||||
'sorting_book_default' => 'Tri des livres par défaut',
|
||||
'sorting_book_default_desc' => 'Sélectionnez le tri par défaut à mettre en place sur les nouveaux livres. Cela n’affectera pas les livres existants, et peut être redéfini dans les livres.',
|
||||
'sorting_rules' => 'Règles de tri',
|
||||
'sorting_rules_desc' => 'Ce sont les opérations de tri qui peuvent être appliquées au contenu du système.',
|
||||
@@ -103,8 +103,8 @@ return [
|
||||
'sort_rule_op_updated_date' => 'Date de mise à jour',
|
||||
'sort_rule_op_chapters_first' => 'Chapitres en premier',
|
||||
'sort_rule_op_chapters_last' => 'Chapitres en dernier',
|
||||
'sorting_page_limits' => 'Per-Page Display Limits',
|
||||
'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.',
|
||||
'sorting_page_limits' => 'Limite d\'affichage par page',
|
||||
'sorting_page_limits_desc' => 'Définissez le nombre d’éléments à afficher par page dans les différentes listes du système. En général, un nombre plus faible offre de meilleures performances, tandis qu’un nombre plus élevé réduit le besoin de naviguer entre plusieurs pages. Il est recommandé d’utiliser un multiple pair de 3 (18, 24, 30, etc.).',
|
||||
|
||||
// Maintenance settings
|
||||
'maint' => 'Maintenance',
|
||||
@@ -197,13 +197,13 @@ return [
|
||||
'role_import_content' => 'Importer le contenu',
|
||||
'role_editor_change' => 'Changer l\'éditeur de page',
|
||||
'role_notifications' => 'Recevoir et gérer les notifications',
|
||||
'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.',
|
||||
'role_permission_note_users_and_roles' => 'Ces autorisations permettront également l\'accès à la consultation et la recherche des utilisateurs et des rôles dans le système.',
|
||||
'role_asset' => 'Permissions des ressources',
|
||||
'roles_system_warning' => 'Sachez que l\'accès à l\'une des trois permissions ci-dessus peut permettre à un utilisateur de modifier ses propres privilèges ou les privilèges des autres utilisateurs du système. N\'attribuez uniquement des rôles avec ces permissions qu\'à des utilisateurs de confiance.',
|
||||
'role_asset_desc' => 'Ces permissions contrôlent l\'accès par défaut des ressources dans le système. Les permissions dans les livres, les chapitres et les pages ignoreront ces permissions',
|
||||
'role_asset_admins' => 'Les administrateurs ont automatiquement accès à tous les contenus mais les options suivantes peuvent afficher ou masquer certaines options de l\'interface.',
|
||||
'role_asset_image_view_note' => 'Cela concerne la visibilité dans le gestionnaire d\'images. L\'accès réel des fichiers d\'image téléchargés dépendra de l\'option de stockage d\'images du système.',
|
||||
'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.',
|
||||
'role_asset_users_note' => 'Ces autorisations permettront également l\'accès à la consultation et la recherche des utilisateurs dans le système.',
|
||||
'role_all' => 'Tous',
|
||||
'role_own' => 'Propres',
|
||||
'role_controlled_by_asset' => 'Contrôlé par les ressources les ayant envoyés',
|
||||
@@ -270,7 +270,7 @@ return [
|
||||
'user_api_token_name_desc' => 'Donnez à votre jeton un nom lisible pour l\'identifier plus tard.',
|
||||
'user_api_token_expiry' => 'Date d\'expiration',
|
||||
'user_api_token_expiry_desc' => 'Définissez une date à laquelle ce jeton expire. Après cette date, les demandes effectuées à l\'aide de ce jeton ne fonctionneront plus. Le fait de laisser ce champ vide entraînera une expiration dans 100 ans.',
|
||||
'user_api_token_create_secret_message' => 'Immédiatement après la création de ce jeton, un "ID de jeton" "et" Secret de jeton "sera généré et affiché. Le secret ne sera affiché qu\'une seule fois, alors assurez-vous de copier la valeur dans un endroit sûr et sécurisé avant de continuer.',
|
||||
'user_api_token_create_secret_message' => 'Immédiatement après la création de ce jeton, un "ID de jeton" et "Secret de jeton" sera généré et affiché. Le secret ne sera affiché qu\'une seule fois, alors assurez-vous de copier la valeur dans un endroit sûr et sécurisé avant de continuer.',
|
||||
'user_api_token' => 'Jeton API',
|
||||
'user_api_token_id' => 'Token ID',
|
||||
'user_api_token_id_desc' => 'Il s\'agit d\'un identifiant généré par le système non modifiable pour ce jeton qui devra être fourni dans les demandes d\'API.',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'Le fichier n\'a pas pu être envoyé. Le serveur peut ne pas accepter des fichiers de cette taille.',
|
||||
|
||||
'zip_file' => 'L\'attribut :attribute doit référencer un fichier dans le ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'Le fichier :attribute ne doit pas dépasser :size Mo.',
|
||||
'zip_file_mime' => ':attribute doit référencer un fichier de type :validTypes, trouvé :foundType.',
|
||||
'zip_model_expected' => 'Objet de données attendu, mais ":type" trouvé.',
|
||||
'zip_unique' => 'L\'attribut :attribute doit être unique pour le type d\'objet dans le ZIP.',
|
||||
|
||||
@@ -85,12 +85,12 @@ return [
|
||||
'webhook_delete_notification' => 'Webhook sikeresen törölve',
|
||||
|
||||
// Imports
|
||||
'import_create' => 'created import',
|
||||
'import_create_notification' => 'Import successfully uploaded',
|
||||
'import_run' => 'updated import',
|
||||
'import_run_notification' => 'Content successfully imported',
|
||||
'import_delete' => 'deleted import',
|
||||
'import_delete_notification' => 'Import successfully deleted',
|
||||
'import_create' => 'import elkészült',
|
||||
'import_create_notification' => 'Az import sikeresen feltöltötve',
|
||||
'import_run' => 'import frissítve',
|
||||
'import_run_notification' => 'A tartalmat sikeresen importáltam.',
|
||||
'import_delete' => 'import törölve',
|
||||
'import_delete_notification' => 'Az import sikeresen törölve',
|
||||
|
||||
// Users
|
||||
'user_create' => 'létrehozta a felhasználót',
|
||||
|
||||
@@ -30,8 +30,8 @@ return [
|
||||
'create' => 'Létrehozás',
|
||||
'update' => 'Frissítés',
|
||||
'edit' => 'Szerkesztés',
|
||||
'archive' => 'Archive',
|
||||
'unarchive' => 'Un-Archive',
|
||||
'archive' => 'Archiválás',
|
||||
'unarchive' => 'Archiválás visszavonása',
|
||||
'sort' => 'Rendezés',
|
||||
'move' => 'Áthelyezés',
|
||||
'copy' => 'Másolás',
|
||||
@@ -111,5 +111,5 @@ return [
|
||||
'terms_of_service' => 'Felhasználási feltételek',
|
||||
|
||||
// OpenSearch
|
||||
'opensearch_description' => 'Search :appName',
|
||||
'opensearch_description' => 'Keresés :appName',
|
||||
];
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'Impossibile leggere il file ZIP.',
|
||||
'import_zip_cant_decode_data' => 'Impossibile trovare e decodificare il contenuto ZIP data.json.',
|
||||
'import_zip_no_data' => 'I dati del file ZIP non hanno il contenuto previsto di libri, capitoli o pagine.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'Il contenuto ZIP data.json supera la dimensione massima di upload configurata nell\'applicazione.',
|
||||
'import_validation_failed' => 'L\'importazione ZIP non è stata convalidata con errori:',
|
||||
'import_zip_failed_notification' => 'Impossibile importare il file ZIP.',
|
||||
'import_perms_books' => 'Non hai i permessi necessari per creare libri.',
|
||||
|
||||
@@ -11,8 +11,8 @@ return [
|
||||
'updated_page_subject' => 'Pagina aggiornata: :pageName',
|
||||
'updated_page_intro' => 'Una pagina è stata aggiornata in :appName:',
|
||||
'updated_page_debounce' => 'Per evitare una massa di notifiche, per un po\' non ti verranno inviate notifiche per ulteriori modifiche a questa pagina dallo stesso editor.',
|
||||
'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName',
|
||||
'comment_mention_intro' => 'You were mentioned in a comment on :appName:',
|
||||
'comment_mention_subject' => 'Sei stato menzionato in un commento nella pagina: :pageName',
|
||||
'comment_mention_intro' => 'Sei stato menzionato in un commento su :appName:',
|
||||
|
||||
'detail_page_name' => 'Nome della pagina:',
|
||||
'detail_page_path' => 'Percorso della pagina:',
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
'notifications_desc' => 'Controlla le notifiche email che ricevi quando viene eseguita una determinata attività all\'interno del sistema.',
|
||||
'notifications_opt_own_page_changes' => 'Notifica in caso di modifiche alle pagine che possiedo',
|
||||
'notifications_opt_own_page_comments' => 'Notifica i commenti sulle pagine che possiedo',
|
||||
'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment',
|
||||
'notifications_opt_comment_mentions' => 'Avvisami quando vengo menzionato in un commento',
|
||||
'notifications_opt_comment_replies' => 'Notificare le risposte ai miei commenti',
|
||||
'notifications_save' => 'Salva preferenze',
|
||||
'notifications_update_success' => 'Le preferenze di notifica sono state aggiornate!',
|
||||
|
||||
@@ -197,13 +197,13 @@ return [
|
||||
'role_import_content' => 'Importa contenuto',
|
||||
'role_editor_change' => 'Cambiare editor di pagina',
|
||||
'role_notifications' => 'Ricevere e gestire le notifiche',
|
||||
'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.',
|
||||
'role_permission_note_users_and_roles' => 'Queste autorizzazioni forniranno tecnicamente anche la visibilità e la ricerca di utenti e ruoli nel sistema.',
|
||||
'role_asset' => 'Permessi entità',
|
||||
'roles_system_warning' => 'Siate consapevoli che l\'accesso a uno dei tre permessi qui sopra può consentire a un utente di modificare i propri privilegi o i privilegi di altri nel sistema. Assegna ruoli con questi permessi solo ad utenti fidati.',
|
||||
'role_asset_desc' => 'Questi permessi controllano l\'accesso predefinito alle entità. I permessi in libri, capitoli e pagine sovrascriveranno questi.',
|
||||
'role_asset_admins' => 'Gli amministratori hanno automaticamente accesso a tutti i contenuti ma queste opzioni possono mostrare o nascondere le opzioni della UI.',
|
||||
'role_asset_image_view_note' => 'Questo si riferisce alla visibilità all\'interno del gestore delle immagini. L\'accesso effettivo ai file di immagine caricati dipenderà dall\'opzione di archiviazione delle immagini di sistema.',
|
||||
'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.',
|
||||
'role_asset_users_note' => 'Queste autorizzazioni forniranno tecnicamente anche la visibilità e la ricerca di utenti nel sistema.',
|
||||
'role_all' => 'Tutti',
|
||||
'role_own' => 'Propri',
|
||||
'role_controlled_by_asset' => 'Controllato dall\'entità in cui sono caricati',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'Il file non può essere caricato. Il server potrebbe non accettare file di questa dimensione.',
|
||||
|
||||
'zip_file' => 'L\'attributo :attribute deve fare riferimento a un file all\'interno dello ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'Il file :attribute non deve superare :size MB.',
|
||||
'zip_file_mime' => 'Il campo :attribute deve fare riferimento a un file di tipo :validTypes, trovato :foundType.',
|
||||
'zip_model_expected' => 'Oggetto dati atteso ma ":type" trovato.',
|
||||
'zip_unique' => 'L\'attributo :attribute deve essere univoco per il tipo di oggetto all\'interno dello ZIP.',
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'ZIPファイルを読み込めません。',
|
||||
'import_zip_cant_decode_data' => 'ZIPファイル内に data.json が見つからないかデコードできませんでした。',
|
||||
'import_zip_no_data' => 'ZIPファイルのデータにブック、チャプター、またはページコンテンツがありません。',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'ZIPに含まれる data.json が、アプリケーションで設定された最大アップロードサイズを超えています。',
|
||||
'import_validation_failed' => 'エラーによりインポートZIPの検証に失敗しました:',
|
||||
'import_zip_failed_notification' => 'ZIP ファイルのインポートに失敗しました。',
|
||||
'import_perms_books' => 'ブックを作成するために必要な権限がありません。',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'ファイルをアップロードできませんでした。サーバーがこのサイズのファイルを受け付けていない可能性があります。',
|
||||
|
||||
'zip_file' => ':attribute はZIP 内のファイルを参照する必要があります。',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => ':attribute は :size MB を超えてはいけません。',
|
||||
'zip_file_mime' => ':attribute は種別 :validType のファイルを参照する必要がありますが、種別 :foundType となっています。',
|
||||
'zip_model_expected' => 'データオブジェクトが期待されますが、":type" が見つかりました。',
|
||||
'zip_unique' => 'ZIP内のオブジェクトタイプに :attribute が一意である必要があります。',
|
||||
|
||||
@@ -33,7 +33,7 @@ return [
|
||||
'book_create_from_chapter' => '챕터를 책으로 변환',
|
||||
'book_create_from_chapter_notification' => '챕터가 책으로 성공적으로 변환되었습니다.',
|
||||
'book_update' => '업데이트된 책',
|
||||
'book_update_notification' => '책이 성공적으로 업데이트되었습니다.',
|
||||
'book_update_notification' => '책 수정함',
|
||||
'book_delete' => '삭제된 책',
|
||||
'book_delete_notification' => '책이 성공적으로 삭제되었습니다.',
|
||||
'book_sort' => '책 정렬',
|
||||
@@ -50,9 +50,9 @@ return [
|
||||
'bookshelf_delete_notification' => '책장이 성공적으로 삭제되었습니다.',
|
||||
|
||||
// Revisions
|
||||
'revision_restore' => '버전 복구',
|
||||
'revision_delete' => '버전 삭제',
|
||||
'revision_delete_notification' => '버전 삭제 성공',
|
||||
'revision_restore' => '복원한 수정본',
|
||||
'revision_delete' => '삭제한 수정본',
|
||||
'revision_delete_notification' => '수정본을 잘 삭제함',
|
||||
|
||||
// Favourites
|
||||
'favourite_add_notification' => '":name" 을 북마크에 추가하였습니다.',
|
||||
|
||||
@@ -17,7 +17,7 @@ return [
|
||||
'logout' => '로그아웃',
|
||||
|
||||
'name' => '이름',
|
||||
'username' => '사용자 이름',
|
||||
'username' => '이용자명',
|
||||
'email' => '전자우편 주소',
|
||||
'password' => '비밀번호',
|
||||
'password_confirm' => '비밀번호 확인',
|
||||
|
||||
@@ -30,8 +30,8 @@ return [
|
||||
'create' => '만들기',
|
||||
'update' => '바꾸기',
|
||||
'edit' => '수정',
|
||||
'archive' => 'Archive',
|
||||
'unarchive' => 'Un-Archive',
|
||||
'archive' => '보관',
|
||||
'unarchive' => '보관 해제',
|
||||
'sort' => '정렬',
|
||||
'move' => '이동',
|
||||
'copy' => '복사',
|
||||
@@ -43,31 +43,31 @@ return [
|
||||
'reset' => '리셋',
|
||||
'remove' => '제거',
|
||||
'add' => '추가',
|
||||
'configure' => '설정',
|
||||
'configure' => '구성',
|
||||
'manage' => '관리',
|
||||
'fullscreen' => '전체화면',
|
||||
'favourite' => '즐겨찾기',
|
||||
'unfavourite' => '즐겨찾기 해제',
|
||||
'next' => '다음',
|
||||
'previous' => '이전',
|
||||
'filter_active' => '적용 중:',
|
||||
'filter_clear' => '모든 필터 해제',
|
||||
'filter_active' => '적용 필터:',
|
||||
'filter_clear' => '필터 해제',
|
||||
'download' => '내려받기',
|
||||
'open_in_tab' => '탭에서 열기',
|
||||
'open' => '열기 ',
|
||||
'open' => '열기',
|
||||
|
||||
// Sort Options
|
||||
'sort_options' => '정렬 기준',
|
||||
'sort_direction_toggle' => '순서 반전',
|
||||
'sort_ascending' => '오름차순',
|
||||
'sort_descending' => '내림차순',
|
||||
'sort_name' => '제목',
|
||||
'sort_name' => '이름',
|
||||
'sort_default' => '기본값',
|
||||
'sort_created_at' => '만든 날짜',
|
||||
'sort_updated_at' => '수정한 날짜',
|
||||
'sort_updated_at' => '갱신한 날짜',
|
||||
|
||||
// Misc
|
||||
'deleted_user' => '삭제한 사용자',
|
||||
'deleted_user' => '삭제한 이용자',
|
||||
'no_activity' => '활동 없음',
|
||||
'no_items' => '항목 없음',
|
||||
'back_to_top' => '맨 위로',
|
||||
@@ -75,8 +75,8 @@ return [
|
||||
'toggle_details' => '내용 보기',
|
||||
'toggle_thumbnails' => '썸네일 보기',
|
||||
'details' => '정보',
|
||||
'grid_view' => '격자 형식으로 보기',
|
||||
'list_view' => '리스트 형식으로 보기',
|
||||
'grid_view' => '격자로 보기',
|
||||
'list_view' => '목록으로 보기',
|
||||
'default' => '기본 설정',
|
||||
'breadcrumb' => '탐색 경로',
|
||||
'status' => '상태',
|
||||
|
||||
@@ -8,7 +8,7 @@ return [
|
||||
'image_select' => '이미지 선택',
|
||||
'image_list' => '이미지 목록',
|
||||
'image_details' => '이미지 상세정보',
|
||||
'image_upload' => '이미지 업로드',
|
||||
'image_upload' => '이미지 올려두기',
|
||||
'image_intro' => '여기에서 이전에 시스템에 업로드한 이미지를 선택하고 관리할 수 있습니다.',
|
||||
'image_intro_upload' => '이미지 파일을 이 창으로 끌어다 놓거나 위의 \'이미지 업로드\' 버튼을 사용하여 새 이미지를 업로드합니다.',
|
||||
'image_all' => '모든 이미지',
|
||||
@@ -17,7 +17,7 @@ return [
|
||||
'image_page_title' => '이 문서에서 쓰고 있는 이미지',
|
||||
'image_search_hint' => '이미지 이름 검색',
|
||||
'image_uploaded' => '올림 :uploadedDate',
|
||||
'image_uploaded_by' => '업로드 :userName',
|
||||
'image_uploaded_by' => ':userName 이용자가 올려둠',
|
||||
'image_uploaded_to' => ':pageLink 로 업로드됨',
|
||||
'image_updated' => '갱신일 :updateDate',
|
||||
'image_load_more' => '더 보기',
|
||||
|
||||
@@ -16,8 +16,8 @@ return [
|
||||
'recently_viewed' => '최근에 본 목록',
|
||||
'recent_activity' => '최근 활동 기록',
|
||||
'create_now' => '바로 만들기',
|
||||
'revisions' => '버전',
|
||||
'meta_revision' => '버전 #:revisionCount',
|
||||
'revisions' => '수정본',
|
||||
'meta_revision' => '수정본 #:revisionCount',
|
||||
'meta_created' => '생성 :timeLength',
|
||||
'meta_created_name' => '생성 :timeLength, :user',
|
||||
'meta_updated' => '수정 :timeLength',
|
||||
@@ -115,7 +115,7 @@ return [
|
||||
'shelves_create' => '책꽂이 만들기',
|
||||
'shelves_popular' => '많이 읽은 책꽂이',
|
||||
'shelves_new' => '새로운 책꽂이',
|
||||
'shelves_new_action' => '새로운 책꽂이',
|
||||
'shelves_new_action' => '새 책꽂이',
|
||||
'shelves_popular_empty' => '많이 읽은 책꽂이 목록',
|
||||
'shelves_new_empty' => '새로운 책꽂이 목록',
|
||||
'shelves_save' => '저장',
|
||||
@@ -148,7 +148,7 @@ return [
|
||||
'books_popular' => '많이 읽은 책',
|
||||
'books_recent' => '최근에 읽은 책',
|
||||
'books_new' => '새로운 책',
|
||||
'books_new_action' => '새로운 책',
|
||||
'books_new_action' => '새 책',
|
||||
'books_popular_empty' => '많이 읽은 책 목록',
|
||||
'books_new_empty' => '새로운 책 목록',
|
||||
'books_create' => '책 만들기',
|
||||
@@ -200,7 +200,7 @@ return [
|
||||
'chapters' => '챕터',
|
||||
'x_chapters' => '챕터 :count개|총 :count개',
|
||||
'chapters_popular' => '많이 읽은 챕터',
|
||||
'chapters_new' => '새로운 챕터',
|
||||
'chapters_new' => '새 장',
|
||||
'chapters_create' => '챕터 만들기',
|
||||
'chapters_delete' => '챕터 삭제하기',
|
||||
'chapters_delete_named' => ':chapterName(을)를 지웁니다.',
|
||||
@@ -221,11 +221,11 @@ return [
|
||||
'chapter_sort_book' => '책 정렬하기',
|
||||
|
||||
// Pages
|
||||
'page' => '문서',
|
||||
'pages' => '문서',
|
||||
'page' => '페이지',
|
||||
'pages' => '페이지',
|
||||
'x_pages' => '문서 :count개|총 :count개',
|
||||
'pages_popular' => '많이 읽은 문서',
|
||||
'pages_new' => '새로운 문서',
|
||||
'pages_new' => '새 페이지',
|
||||
'pages_attachments' => '첨부',
|
||||
'pages_navigation' => '목차',
|
||||
'pages_delete' => '문서 삭제하기',
|
||||
@@ -272,7 +272,7 @@ return [
|
||||
'pages_md_insert_drawing' => '드로잉 추가',
|
||||
'pages_md_show_preview' => '미리보기 표시',
|
||||
'pages_md_sync_scroll' => '미리보기 스크롤 동기화',
|
||||
'pages_md_plain_editor' => 'Plaintext editor',
|
||||
'pages_md_plain_editor' => '플레인텍스트 편집기',
|
||||
'pages_drawing_unsaved' => '저장되지 않은 드로잉 발견',
|
||||
'pages_drawing_unsaved_confirm' => '이전에 실패한 드로잉 저장 시도에서 저장되지 않은 드로잉 데이터가 발견되었습니다. 이 저장되지 않은 드로잉을 복원하고 계속 편집하시겠습니까?',
|
||||
'pages_not_in_chapter' => '챕터에 있는 문서가 아닙니다.',
|
||||
@@ -290,10 +290,10 @@ return [
|
||||
'pages_revision_restored_from' => '#:id; :summary에서 복구함',
|
||||
'pages_revisions_created_by' => '만든 사용자',
|
||||
'pages_revisions_date' => '수정한 날짜',
|
||||
'pages_revisions_number' => 'No.',
|
||||
'pages_revisions_number' => '#',
|
||||
'pages_revisions_sort_number' => '수정 번호',
|
||||
'pages_revisions_numbered' => '수정본 :id',
|
||||
'pages_revisions_numbered_changes' => '수정본 :id에서 바꾼 부분',
|
||||
'pages_revisions_numbered' => '수정본 #:id',
|
||||
'pages_revisions_numbered_changes' => '수정본 #:id에서 바꾼 부분',
|
||||
'pages_revisions_editor' => '편집기 유형',
|
||||
'pages_revisions_changelog' => '설명',
|
||||
'pages_revisions_changes' => '바꾼 부분',
|
||||
@@ -310,7 +310,7 @@ return [
|
||||
'pages_pointer_toggle_link' => '퍼머링크 모드, 포함 태그를 표시하려면 누릅니다.',
|
||||
'pages_pointer_toggle_include' => '태그 포함 모드, 퍼머링크를 표시하려면 누릅니다.',
|
||||
'pages_permissions_active' => '문서 권한 허용함',
|
||||
'pages_initial_revision' => '처음 판본',
|
||||
'pages_initial_revision' => '최초 게시',
|
||||
'pages_references_update_revision' => '시스템에서 내부 링크 자동 업데이트',
|
||||
'pages_initial_name' => '제목 없음',
|
||||
'pages_editing_draft_notification' => ':timeDiff에 초안 문서입니다.',
|
||||
@@ -330,27 +330,27 @@ return [
|
||||
|
||||
// Editor Sidebar
|
||||
'toggle_sidebar' => '사이드바 토글',
|
||||
'page_tags' => '문서 태그',
|
||||
'chapter_tags' => '챕터 꼬리표',
|
||||
'book_tags' => '책 꼬리표',
|
||||
'shelf_tags' => '책꽂이 꼬리표',
|
||||
'tag' => '꼬리표',
|
||||
'tags' => '꼬리표',
|
||||
'page_tags' => '페이지 태그',
|
||||
'chapter_tags' => '장 태그',
|
||||
'book_tags' => '책 태그',
|
||||
'shelf_tags' => '책꽂이 태그',
|
||||
'tag' => '태그',
|
||||
'tags' => '태그',
|
||||
'tags_index_desc' => '태그를 시스템 내의 콘텐츠에 적용하여 유연한 형태의 분류를 적용할 수 있습니다. 태그는 키와 값을 모두 가질 수 있으며 값은 선택 사항입니다. 태그가 적용되면 태그 이름과 값을 사용하여 콘텐츠를 쿼리할 수 있습니다.',
|
||||
'tag_name' => '꼬리표 이름',
|
||||
'tag_name' => '태그 이름',
|
||||
'tag_value' => '리스트 값 (선택 사항)',
|
||||
'tags_explain' => "문서를 더 잘 분류하려면 태그를 추가하세요.\n태그에 값을 할당하여 더욱 체계적으로 구성할 수 있습니다.",
|
||||
'tags_add' => '꼬리표 추가',
|
||||
'tags_remove' => '꼬리표 삭제',
|
||||
'tags_usages' => '모든 꼬리표',
|
||||
'tags_assigned_pages' => '문서에 꼬리표 지정함',
|
||||
'tags_assigned_chapters' => '챕터에 꼬리표 지정함',
|
||||
'tags_assigned_books' => '책에 태그 지정함',
|
||||
'tags_assigned_shelves' => '책꽂이에 꼬리표 지정함',
|
||||
'tags_add' => '다른 태그 추가하기',
|
||||
'tags_remove' => '이 태그 제거하기',
|
||||
'tags_usages' => '전체 태그 이용량',
|
||||
'tags_assigned_pages' => '| 페이지 태그 할당 |',
|
||||
'tags_assigned_chapters' => '| 장 태그 할당 |',
|
||||
'tags_assigned_books' => '| 책 태그 할당 |',
|
||||
'tags_assigned_shelves' => '| 책꽂이 태그 할당 |',
|
||||
'tags_x_unique_values' => ':count 중복 없는 값',
|
||||
'tags_all_values' => '모든 값',
|
||||
'tags_view_tags' => '꼬리표 보기',
|
||||
'tags_view_existing_tags' => '사용 중인 꼬리표 보기',
|
||||
'tags_view_tags' => '태그 보기',
|
||||
'tags_view_existing_tags' => '기존 태그 보기',
|
||||
'tags_list_empty_hint' => '태그는 에디터 사이드바나 책, 챕터 또는 책꽂이 정보 편집에서 지정할 수 있습니다.',
|
||||
'attachments' => '첨부 파일',
|
||||
'attachments_explain' => '파일이나 링크를 첨부하세요. 정보 탭에 나타납니다.',
|
||||
@@ -403,7 +403,7 @@ return [
|
||||
'comment_archived_count' => ':count Archived',
|
||||
'comment_archived_threads' => 'Archived Threads',
|
||||
'comment_save' => '등록',
|
||||
'comment_new' => '새로운 댓글',
|
||||
'comment_new' => '새 의견',
|
||||
'comment_created' => '댓글 등록함 :createDiff',
|
||||
'comment_updated' => ':username(이)가 댓글 수정함 :updateDiff',
|
||||
'comment_updated_indicator' => '업데이트됨',
|
||||
@@ -416,14 +416,14 @@ return [
|
||||
'comment_jump_to_thread' => 'Jump to thread',
|
||||
'comment_delete_confirm' => '이 댓글을 지울 건가요?',
|
||||
'comment_in_reply_to' => ':commentId(을)를 향한 답글',
|
||||
'comment_reference' => 'Reference',
|
||||
'comment_reference' => '참조',
|
||||
'comment_reference_outdated' => '(Outdated)',
|
||||
'comment_editor_explain' => '이 페이지에 남겨진 댓글은 다음과 같습니다. 저장된 페이지를 볼 때 댓글을 추가하고 관리할 수 있습니다.',
|
||||
|
||||
// Revision
|
||||
'revision_delete_confirm' => '이 수정본을 지울 건가요?',
|
||||
'revision_restore_confirm' => '이 버전을 되돌릴 건가요? 현재 페이지는 대체됩니다.',
|
||||
'revision_cannot_delete_latest' => '현재 버전본은 지울 수 없습니다.',
|
||||
'revision_cannot_delete_latest' => '최신 수정본은 지울 수 없습니다.',
|
||||
|
||||
// Copy view
|
||||
'copy_consider' => '항목을 복사할 때 다음을 고려하세요.',
|
||||
|
||||
@@ -18,13 +18,13 @@ return [
|
||||
'app_name' => '애플리케이션 이름 (사이트 제목)',
|
||||
'app_name_desc' => '이 이름은 헤더와 시스템에서 보낸 모든 이메일에 표시됩니다.',
|
||||
'app_name_header' => '헤더에 이름 표시',
|
||||
'app_public_access' => '사이트 공개',
|
||||
'app_public_access_desc' => '이 옵션을 활성화하면 로그인하지 않은 방문자도 이 서버의 콘텐츠에 액세스할 수 있습니다.',
|
||||
'app_public_access' => '공개 접근',
|
||||
'app_public_access_desc' => '이 옵션을 활성화하면 로그인하지 않은 방문자가 BookStack 인스턴스의 내용에 접근할 수 있습니다.',
|
||||
'app_public_access_desc_guest' => '일반 방문자의 액세스는 "Guest" 사용자를 통해 제어할 수 있습니다.',
|
||||
'app_public_access_toggle' => '공개 액세스 허용',
|
||||
'app_public_viewing' => '공개 열람을 허용할까요?',
|
||||
'app_secure_images' => '보안 강화된 이미지 업로드',
|
||||
'app_secure_images_toggle' => '보안 강화된 이미지 업로드 사용',
|
||||
'app_secure_images' => '보안을 강화하여 이미지 올려두기',
|
||||
'app_secure_images_toggle' => '보안을 강화하여 이미지 올려두기 활성화',
|
||||
'app_secure_images_desc' => '성능상의 이유로 모든 이미지는 공개됩니다. 이 옵션은 이미지 URL 앞에 추측하기 어려운 임의의 문자열을 추가합니다. 쉽게 액세스할 수 없도록 디렉토리 인덱스가 활성화되어 있지 않은지 확인하세요.',
|
||||
'app_default_editor' => '기본 페이지 편집기',
|
||||
'app_default_editor_desc' => '새 페이지를 편집할 때 기본으로 사용될 편집기를 선택합니다. 권한을 갖고 있다면 페이지마다 다르게 적용될 수 있습니다.',
|
||||
@@ -157,7 +157,7 @@ return [
|
||||
'audit_event_filter_no_filter' => '필터 없음',
|
||||
'audit_deleted_item' => '삭제한 항목',
|
||||
'audit_deleted_item_name' => '이름: :name',
|
||||
'audit_table_user' => '사용자',
|
||||
'audit_table_user' => '이용자',
|
||||
'audit_table_event' => '이벤트',
|
||||
'audit_table_related' => '관련 항목 또는 세부 사항',
|
||||
'audit_table_ip' => 'IP 주소',
|
||||
@@ -167,7 +167,7 @@ return [
|
||||
|
||||
// Role Settings
|
||||
'roles' => '역할',
|
||||
'role_user_roles' => '사용자 역할',
|
||||
'role_user_roles' => '이용자 역할',
|
||||
'roles_index_desc' => '역할은 사용자를 그룹화하고 구성원에게 시스템 권한을 제공하기 위해 사용됩니다. 사용자가 여러 역할의 구성원인 경우 부여된 권한이 중첩되며 모든 권한을 상속받게 됩니다.',
|
||||
'roles_x_users_assigned' => ':count 명의 사용자가 할당됨|:count 명의 사용자가 할당됨',
|
||||
'roles_x_permissions_provided' => ':count 개의 권한|:count 개의 권한',
|
||||
@@ -186,7 +186,7 @@ return [
|
||||
'role_mfa_enforced' => '다중 인증 필요',
|
||||
'role_external_auth_id' => '외부 인증 계정',
|
||||
'role_system' => '시스템 권한',
|
||||
'role_manage_users' => '사용자 관리',
|
||||
'role_manage_users' => '이용자 관리하기',
|
||||
'role_manage_roles' => '권한 관리',
|
||||
'role_manage_entity_permissions' => '문서별 권한 관리',
|
||||
'role_manage_own_entity_permissions' => '직접 만든 문서별 권한 관리',
|
||||
@@ -217,7 +217,7 @@ return [
|
||||
'user_profile' => '사용자 프로필',
|
||||
'users_add_new' => '사용자 만들기',
|
||||
'users_search' => '사용자 검색',
|
||||
'users_latest_activity' => '마지막 활동',
|
||||
'users_latest_activity' => '최근 활동',
|
||||
'users_details' => '사용자 정보',
|
||||
'users_details_desc' => '메일 주소로 로그인합니다.',
|
||||
'users_details_desc_no_email' => '사용자 이름을 바꿉니다.',
|
||||
|
||||
@@ -30,8 +30,8 @@ return [
|
||||
'create' => 'Opprett',
|
||||
'update' => 'Oppdater',
|
||||
'edit' => 'Rediger',
|
||||
'archive' => 'Archive',
|
||||
'unarchive' => 'Un-Archive',
|
||||
'archive' => 'Arkiver',
|
||||
'unarchive' => 'Av-arkiver',
|
||||
'sort' => 'Sortér',
|
||||
'move' => 'Flytt',
|
||||
'copy' => 'Kopier',
|
||||
|
||||
@@ -63,10 +63,10 @@ return [
|
||||
'import_delete_desc' => 'Dette vil slette den opplastede importen av ZIP-filen og kan ikke angres.',
|
||||
'import_errors' => 'Import feil',
|
||||
'import_errors_desc' => 'Feil oppstod under importforsøket:',
|
||||
'breadcrumb_siblings_for_page' => 'Navigate siblings for page',
|
||||
'breadcrumb_siblings_for_chapter' => 'Navigate siblings for chapter',
|
||||
'breadcrumb_siblings_for_book' => 'Navigate siblings for book',
|
||||
'breadcrumb_siblings_for_bookshelf' => 'Navigate siblings for shelf',
|
||||
'breadcrumb_siblings_for_page' => 'Naviger relaterte sider',
|
||||
'breadcrumb_siblings_for_chapter' => 'Naviger relaterte kapitler',
|
||||
'breadcrumb_siblings_for_book' => 'Naviger relaterte bøker',
|
||||
'breadcrumb_siblings_for_bookshelf' => 'Naviger relaterte hyller',
|
||||
|
||||
// Permissions and restrictions
|
||||
'permissions' => 'Tilganger',
|
||||
@@ -252,7 +252,7 @@ return [
|
||||
'pages_edit_switch_to_markdown_stable' => '(Urørt innhold)',
|
||||
'pages_edit_switch_to_wysiwyg' => 'Bytt til WYSIWYG tekstredigering',
|
||||
'pages_edit_switch_to_new_wysiwyg' => 'Bytt til ny WYSIWYG',
|
||||
'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)',
|
||||
'pages_edit_switch_to_new_wysiwyg_desc' => '(under Beta-testing)',
|
||||
'pages_edit_set_changelog' => 'Angi endringslogg',
|
||||
'pages_edit_enter_changelog_desc' => 'Gi en kort beskrivelse av endringene dine',
|
||||
'pages_edit_enter_changelog' => 'Se endringslogg',
|
||||
@@ -272,7 +272,7 @@ return [
|
||||
'pages_md_insert_drawing' => 'Sett inn tegning',
|
||||
'pages_md_show_preview' => 'Forhåndsvisning',
|
||||
'pages_md_sync_scroll' => 'Synkroniser forhåndsvisningsrulle',
|
||||
'pages_md_plain_editor' => 'Plaintext editor',
|
||||
'pages_md_plain_editor' => 'Redigeringsverktøy for klartekst',
|
||||
'pages_drawing_unsaved' => 'Ulagret tegning funnet',
|
||||
'pages_drawing_unsaved_confirm' => 'Ulagret tegningsdata ble funnet fra en tidligere mislykket lagring. Vil du gjenopprette og fortsette å redigere denne ulagrede tegningen?',
|
||||
'pages_not_in_chapter' => 'Siden tilhører ingen kapittel',
|
||||
@@ -397,11 +397,11 @@ return [
|
||||
'comment' => 'Kommentar',
|
||||
'comments' => 'Kommentarer',
|
||||
'comment_add' => 'Skriv kommentar',
|
||||
'comment_none' => 'No comments to display',
|
||||
'comment_none' => 'Ingen kommentarer å vise',
|
||||
'comment_placeholder' => 'Skriv en kommentar her',
|
||||
'comment_thread_count' => ':count Comment Thread|:count Comment Threads',
|
||||
'comment_archived_count' => ':count Archived',
|
||||
'comment_archived_threads' => 'Archived Threads',
|
||||
'comment_thread_count' => ':count Kommentar Tråd|:count Kommentar Tråder',
|
||||
'comment_archived_count' => ':count Arkivert',
|
||||
'comment_archived_threads' => 'Arkiverte tråder',
|
||||
'comment_save' => 'Publiser kommentar',
|
||||
'comment_new' => 'Ny kommentar',
|
||||
'comment_created' => 'kommenterte :createDiff',
|
||||
@@ -410,14 +410,14 @@ return [
|
||||
'comment_deleted_success' => 'Kommentar fjernet',
|
||||
'comment_created_success' => 'Kommentar skrevet',
|
||||
'comment_updated_success' => 'Kommentar endret',
|
||||
'comment_archive_success' => 'Comment archived',
|
||||
'comment_unarchive_success' => 'Comment un-archived',
|
||||
'comment_view' => 'View comment',
|
||||
'comment_jump_to_thread' => 'Jump to thread',
|
||||
'comment_archive_success' => 'Kommentar arkivert',
|
||||
'comment_unarchive_success' => 'Kommentar uarkivert',
|
||||
'comment_view' => 'Vis kommentar',
|
||||
'comment_jump_to_thread' => 'Gå til tråd',
|
||||
'comment_delete_confirm' => 'Er du sikker på at du vil fjerne kommentaren?',
|
||||
'comment_in_reply_to' => 'Som svar til :commentId',
|
||||
'comment_reference' => 'Reference',
|
||||
'comment_reference_outdated' => '(Outdated)',
|
||||
'comment_reference' => 'Referanse',
|
||||
'comment_reference_outdated' => '(Utdatert)',
|
||||
'comment_editor_explain' => 'Her er kommentarene som er på denne siden. Kommentarer kan legges til og administreres når du ser på den lagrede siden.',
|
||||
|
||||
// Revision
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'Kunne ikke lese ZIP-filen.',
|
||||
'import_zip_cant_decode_data' => 'Kunne ikke finne og dekode ZIP data.json innhold.',
|
||||
'import_zip_no_data' => 'ZIP-fildata har ingen forventet bok, kapittel eller sideinnhold.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json innholdet overskrider maksimal filstørrelse for opplasting.',
|
||||
'import_validation_failed' => 'Import av ZIP feilet i å validere med feil:',
|
||||
'import_zip_failed_notification' => 'Kunne ikke importere ZIP-fil.',
|
||||
'import_perms_books' => 'Du mangler nødvendige tillatelser for å lage bøker.',
|
||||
|
||||
@@ -11,8 +11,8 @@ return [
|
||||
'updated_page_subject' => 'Oppdatert side: :pageName',
|
||||
'updated_page_intro' => 'En side er oppdatert i :appName:',
|
||||
'updated_page_debounce' => 'For å forhindre mange varslinger, vil du ikke få nye varslinger for endringer på denne siden fra samme forfatter.',
|
||||
'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName',
|
||||
'comment_mention_intro' => 'You were mentioned in a comment on :appName:',
|
||||
'comment_mention_subject' => 'Du har blitt nevnt i en kommentar på siden: :pageName',
|
||||
'comment_mention_intro' => 'Du har blitt nevnt i en kommentar på :appName:',
|
||||
|
||||
'detail_page_name' => 'Sidenavn:',
|
||||
'detail_page_path' => 'Side bane:',
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
'notifications_desc' => 'Kontroller e-postvarslene du mottar når en bestemt aktivitet utføres i systemet.',
|
||||
'notifications_opt_own_page_changes' => 'Varsle ved endringer til sider jeg eier',
|
||||
'notifications_opt_own_page_comments' => 'Varsle om kommentarer på sider jeg eier',
|
||||
'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment',
|
||||
'notifications_opt_comment_mentions' => 'Varsle når jeg blir nevnt i en kommentar',
|
||||
'notifications_opt_comment_replies' => 'Varsle ved svar på mine kommentarer',
|
||||
'notifications_save' => 'Lagre innstillinger',
|
||||
'notifications_update_success' => 'Varslingsinnstillingene er oppdatert!',
|
||||
|
||||
@@ -75,8 +75,8 @@ return [
|
||||
'reg_confirm_restrict_domain_placeholder' => 'Ingen begrensninger er satt',
|
||||
|
||||
// Sorting Settings
|
||||
'sorting' => 'Lists & Sorting',
|
||||
'sorting_book_default' => 'Default Book Sort Rule',
|
||||
'sorting' => 'Lister & Sortering',
|
||||
'sorting_book_default' => 'Standard regel for boksortering',
|
||||
'sorting_book_default_desc' => 'Velg standard sorteringsregelen som skal brukes for nye bøker. Dette vil ikke påvirke eksisterende bøker, og kan overstyres per bok.',
|
||||
'sorting_rules' => 'Sorteringsregler',
|
||||
'sorting_rules_desc' => 'Dette er forhåndsdefinerte sorteringsoperasjoner som kan brukes på innhold i systemet.',
|
||||
@@ -91,20 +91,20 @@ return [
|
||||
'sort_rule_details_desc' => 'Angi et navn for denne sorteringsregelen, som vil vises i lister når brukerne velger en sorteringsmetode.',
|
||||
'sort_rule_operations' => 'Sorteringsoperasjoner',
|
||||
'sort_rule_operations_desc' => 'Konfigurer sorteringshandlinger ved å flytte dem fra listen over tilgjengelige operasjoner. Ved bruk vil operasjonene bli brukt i rekkefølge, fra topp til bunn. Eventuelle endringer gjort her vil bli brukt for alle tildelte bøker når du lagrer.',
|
||||
'sort_rule_available_operations' => 'Available Operations',
|
||||
'sort_rule_available_operations_empty' => 'No operations remaining',
|
||||
'sort_rule_configured_operations' => 'Configured Operations',
|
||||
'sort_rule_available_operations' => 'Tilgjengelige operasjoner',
|
||||
'sort_rule_available_operations_empty' => 'Ingen gjenværende operasjoner',
|
||||
'sort_rule_configured_operations' => 'Konfigurerte operasjoner',
|
||||
'sort_rule_configured_operations_empty' => 'Dra/legg til operasjoner fra listen "Tilgjengelige operasjoner"',
|
||||
'sort_rule_op_asc' => '(Asc)',
|
||||
'sort_rule_op_desc' => '(Desc)',
|
||||
'sort_rule_op_asc' => '(Stigende)',
|
||||
'sort_rule_op_desc' => '(Synkende)',
|
||||
'sort_rule_op_name' => 'Navn - Alfabetisk',
|
||||
'sort_rule_op_name_numeric' => 'Name - Numeric',
|
||||
'sort_rule_op_created_date' => 'Created Date',
|
||||
'sort_rule_op_updated_date' => 'Updated Date',
|
||||
'sort_rule_op_name_numeric' => 'Navn - Numerisk',
|
||||
'sort_rule_op_created_date' => 'Dato opprettet',
|
||||
'sort_rule_op_updated_date' => 'Dato oppdatert',
|
||||
'sort_rule_op_chapters_first' => 'Kapitler først',
|
||||
'sort_rule_op_chapters_last' => 'Kapitler sist',
|
||||
'sorting_page_limits' => 'Per-Page Display Limits',
|
||||
'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.',
|
||||
'sorting_page_limits' => 'Visningsgrenser for hver side',
|
||||
'sorting_page_limits_desc' => 'Angi hvor mange elementer som skal vises på hver side i ulike lister i systemet. Et lavere antall vil vanligvis gi bedre ytelse, mens et høyere antall reduserer behovet for å bla gjennom mange sider. Det er anbefalt å bruke en multiplikasjon av 3 som gir partall (18, 24, 30 osv.).',
|
||||
|
||||
// Maintenance settings
|
||||
'maint' => 'Vedlikehold',
|
||||
@@ -197,13 +197,13 @@ return [
|
||||
'role_import_content' => 'Import innhold',
|
||||
'role_editor_change' => 'Endre sideredigering',
|
||||
'role_notifications' => 'Motta og administrere varslinger',
|
||||
'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.',
|
||||
'role_permission_note_users_and_roles' => 'Disse tillatelsene vil teknisk sett også gi mulighet til å se & søke etter brukere & roller i systemet.',
|
||||
'role_asset' => 'Eiendomstillatelser',
|
||||
'roles_system_warning' => 'Vær oppmerksom på at tilgang til noen av de ovennevnte tre tillatelsene kan tillate en bruker å endre sine egne rettigheter eller rettighetene til andre i systemet. Bare tildel roller med disse tillatelsene til pålitelige brukere.',
|
||||
'role_asset_desc' => 'Disse tillatelsene kontrollerer standard tilgang til eiendelene i systemet. Tillatelser til bøker, kapitler og sider overstyrer disse tillatelsene.',
|
||||
'role_asset_admins' => 'Administratorer får automatisk tilgang til alt innhold, men disse alternativene kan vise eller skjule UI-alternativer.',
|
||||
'role_asset_image_view_note' => 'Dette gjelder synlighet innenfor bilde-administrasjonen. Faktisk tilgang på opplastede bildefiler vil være avhengig av valget for systemlagring av bildet.',
|
||||
'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.',
|
||||
'role_asset_users_note' => 'Disse tillatelsene vil teknisk sett også gi mulighet til å se & søke etter brukere i systemet.',
|
||||
'role_all' => 'Alle',
|
||||
'role_own' => 'Egne',
|
||||
'role_controlled_by_asset' => 'Kontrollert av eiendelen de er lastet opp til',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.',
|
||||
|
||||
'zip_file' => 'Attributtet :attribute må henvises til en fil i ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'Filen :attribute må ikke overstige :size MB.',
|
||||
'zip_file_mime' => 'Attributtet :attribute må referere en fil av typen :validTypes, som ble funnet :foundType.',
|
||||
'zip_model_expected' => 'Data objekt forventet, men ":type" funnet.',
|
||||
'zip_unique' => 'Attributtet :attribute må være unikt for objekttypen i ZIP.',
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'Kon het Zip-bestand niet lezen.',
|
||||
'import_zip_cant_decode_data' => 'Kon de data.json Zip-inhoud niet vinden of decoderen.',
|
||||
'import_zip_no_data' => 'Zip-bestand bevat niet de verwachte boek, hoofdstuk of pagina-inhoud.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'De inhoud van data.json in de ZIP overschrijdt de ingestelde maximum upload grootte.',
|
||||
'import_validation_failed' => 'De validatie van het Zip-bestand is mislukt met de volgende fouten:',
|
||||
'import_zip_failed_notification' => 'Importeren van het Zip-bestand is mislukt.',
|
||||
'import_perms_books' => 'Je mist de vereiste machtigingen om boeken te maken.',
|
||||
|
||||
@@ -11,8 +11,8 @@ return [
|
||||
'updated_page_subject' => 'Aangepaste pagina: :pageName',
|
||||
'updated_page_intro' => 'Een pagina werd aangepast in :appName:',
|
||||
'updated_page_debounce' => 'Om een stortvloed aan meldingen te voorkomen, zul je een tijdje geen meldingen ontvangen voor verdere bewerkingen van deze pagina door dezelfde redacteur.',
|
||||
'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName',
|
||||
'comment_mention_intro' => 'You were mentioned in a comment on :appName:',
|
||||
'comment_mention_subject' => 'Je bent vermeld in een opmerking op pagina: :pageName',
|
||||
'comment_mention_intro' => 'Je bent vermeld in een opmerking in :appName:',
|
||||
|
||||
'detail_page_name' => 'Pagina Naam:',
|
||||
'detail_page_path' => 'Paginapad:',
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
'notifications_desc' => 'Bepaal welke e-mailmeldingen je ontvangt wanneer bepaalde activiteiten in het systeem worden uitgevoerd.',
|
||||
'notifications_opt_own_page_changes' => 'Geef melding bij wijzigingen aan pagina\'s waarvan ik de eigenaar ben',
|
||||
'notifications_opt_own_page_comments' => 'Geef melding van opmerkingen op pagina\'s waarvan ik de eigenaar ben',
|
||||
'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment',
|
||||
'notifications_opt_comment_mentions' => 'Geef een melding wanneer ik word vermeld in een opmerking',
|
||||
'notifications_opt_comment_replies' => 'Geef melding van reacties op mijn opmerkingen',
|
||||
'notifications_save' => 'Voorkeuren opslaan',
|
||||
'notifications_update_success' => 'Voorkeuren voor meldingen zijn bijgewerkt!',
|
||||
|
||||
@@ -197,13 +197,13 @@ return [
|
||||
'role_import_content' => 'Importeer inhoud',
|
||||
'role_editor_change' => 'Wijzig pagina bewerker',
|
||||
'role_notifications' => 'Meldingen ontvangen & beheren',
|
||||
'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.',
|
||||
'role_permission_note_users_and_roles' => 'Deze machtigingen geven technisch gezien toegang tot het weergeven van gebruikers & rollen binnen het systeem.',
|
||||
'role_asset' => 'Asset Machtigingen',
|
||||
'roles_system_warning' => 'Wees ervan bewust dat toegang tot een van de bovengenoemde drie machtigingen een gebruiker in staat kan stellen zijn eigen machtigingen of de machtigingen van anderen in het systeem kan wijzigen. Wijs alleen rollen toe met deze machtigingen aan vertrouwde gebruikers.',
|
||||
'role_asset_desc' => 'Deze machtigingen bepalen de standaard toegang tot de assets binnen het systeem. Machtigingen op boeken, hoofdstukken en pagina\'s overschrijven deze instelling.',
|
||||
'role_asset_admins' => 'Beheerders krijgen automatisch toegang tot alle inhoud, maar deze opties kunnen gebruikersinterface opties tonen of verbergen.',
|
||||
'role_asset_image_view_note' => 'Dit heeft betrekking op de zichtbaarheid binnen de afbeeldingsbeheerder. De werkelijke toegang tot geüploade afbeeldingsbestanden hangt af van de gekozen opslagmethode.',
|
||||
'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.',
|
||||
'role_asset_users_note' => 'Deze machtigingen geven technisch gezien toegang tot het weergeven van gebruikers binnen het systeem.',
|
||||
'role_all' => 'Alles',
|
||||
'role_own' => 'Eigen',
|
||||
'role_controlled_by_asset' => 'Gecontroleerd door de asset waar deze is geüpload',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'Het bestand kon niet worden geüpload. De server accepteert mogelijk geen bestanden van deze grootte.',
|
||||
|
||||
'zip_file' => 'Het \':attribute\' veld moet verwijzen naar een bestand in de ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'Het bestand :attribute mag niet groter zijn dan :size MB.',
|
||||
'zip_file_mime' => 'Het \':attribute\' veld moet verwijzen naar een bestand met het type :validTypes, vond :foundType.',
|
||||
'zip_model_expected' => 'Dataobject verwacht maar vond ":type".',
|
||||
'zip_unique' => ':attribute moet uniek zijn voor het objecttype binnen de ZIP.',
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => 'Не вдалося прочитати ZIP-файл.',
|
||||
'import_zip_cant_decode_data' => 'Не вдалося знайти і розшифрувати контент ZIP data.json.',
|
||||
'import_zip_no_data' => 'ZIP-файл не містить очікуваної книги, глави або вмісту сторінки.',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'Вміст ZIP data.json перевищує налаштований максимальний розмір додатка.',
|
||||
'import_validation_failed' => 'Не вдалося виконати перевірку ZIP-адреси із помилками:',
|
||||
'import_zip_failed_notification' => 'Не вдалося імпортувати ZIP-файл.',
|
||||
'import_perms_books' => 'У Вас не вистачає необхідних прав для створення книг.',
|
||||
|
||||
@@ -11,8 +11,8 @@ return [
|
||||
'updated_page_subject' => 'Оновлено сторінку: :pageName',
|
||||
'updated_page_intro' => 'Оновлено сторінку у :appName:',
|
||||
'updated_page_debounce' => 'Для запобігання кількості сповіщень, деякий час ви не будете відправлені повідомлення для подальших змін на цій сторінці тим самим редактором.',
|
||||
'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName',
|
||||
'comment_mention_intro' => 'You were mentioned in a comment on :appName:',
|
||||
'comment_mention_subject' => 'Вас згадали в коментарях на сторінці: :pageName',
|
||||
'comment_mention_intro' => 'Вас згадали в коментарі до :appName:',
|
||||
|
||||
'detail_page_name' => 'Назва сторінки:',
|
||||
'detail_page_path' => 'Шлях до сторінки:',
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
'notifications_desc' => 'Контролюйте сповіщення по електронній пошті, які ви отримуєте, коли виконується певна активність у системі.',
|
||||
'notifications_opt_own_page_changes' => 'Повідомляти при змінах сторінок якими я володію',
|
||||
'notifications_opt_own_page_comments' => 'Повідомляти при коментарях на моїх сторінках',
|
||||
'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment',
|
||||
'notifications_opt_comment_mentions' => 'Сповіщати, якщо мене згадали у коментарі',
|
||||
'notifications_opt_comment_replies' => 'Повідомляти про відповіді на мої коментарі',
|
||||
'notifications_save' => 'Зберегти налаштування',
|
||||
'notifications_update_success' => 'Налаштування сповіщень було оновлено!',
|
||||
|
||||
@@ -75,8 +75,8 @@ return [
|
||||
'reg_confirm_restrict_domain_placeholder' => 'Не встановлено обмежень',
|
||||
|
||||
// Sorting Settings
|
||||
'sorting' => 'Lists & Sorting',
|
||||
'sorting_book_default' => 'Default Book Sort Rule',
|
||||
'sorting' => 'Списки і сортування',
|
||||
'sorting_book_default' => 'Типовий порядок сортування книги',
|
||||
'sorting_book_default_desc' => 'Виберіть правило сортування за замовчуванням для застосування нових книг. Це не вплине на існуючі книги, і може бути перевизначено для кожної книги.',
|
||||
'sorting_rules' => 'Сортувати правила',
|
||||
'sorting_rules_desc' => 'Це попередньо визначені операції сортування, які можуть бути застосовані до вмісту в системі.',
|
||||
@@ -103,8 +103,8 @@ return [
|
||||
'sort_rule_op_updated_date' => 'Дата оновлення',
|
||||
'sort_rule_op_chapters_first' => 'Спочатку розділи',
|
||||
'sort_rule_op_chapters_last' => 'Розділи останні',
|
||||
'sorting_page_limits' => 'Per-Page Display Limits',
|
||||
'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.',
|
||||
'sorting_page_limits' => 'Обмеження відображення сторінок',
|
||||
'sorting_page_limits_desc' => 'Кількість елементів для відображення в різних списках в системі. Зазвичай менша кількість буде більш продуктивною, в той час як більша кількість уникає необхідність натискання на кілька сторінок. Рекомендується використовувати парне кратне 3 (18, 24, 30 тощо).',
|
||||
|
||||
// Maintenance settings
|
||||
'maint' => 'Обслуговування',
|
||||
@@ -197,13 +197,13 @@ return [
|
||||
'role_import_content' => 'Імпортувати вміст',
|
||||
'role_editor_change' => 'Змінити редактор сторінок',
|
||||
'role_notifications' => 'Отримувати та керувати повідомленнями',
|
||||
'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.',
|
||||
'role_permission_note_users_and_roles' => 'Ці дозволи технічно також забезпечать видимість і пошук ролей у системі.',
|
||||
'role_asset' => 'Дозволи',
|
||||
'roles_system_warning' => 'Майте на увазі, що доступ до будь-якого з вищезазначених трьох дозволів може дозволити користувачеві змінювати власні привілеї або привілеї інших в системі. Ролі з цими дозволами призначайте лише довіреним користувачам.',
|
||||
'role_asset_desc' => 'Ці дозволи контролюють стандартні доступи всередині системи. Права на книги, розділи та сторінки перевизначать ці дозволи.',
|
||||
'role_asset_admins' => 'Адміністратори автоматично отримують доступ до всього вмісту, але ці параметри можуть відображати або приховувати параметри інтерфейсу користувача.',
|
||||
'role_asset_image_view_note' => 'Це стосується видимості в менеджері зображень. Фактичний доступ завантажуваних зображень буде залежний від опції зберігання системних зображень.',
|
||||
'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.',
|
||||
'role_asset_users_note' => 'Ці дозволи технічно також забезпечать видимість і пошук користувачів і ролей у системі.',
|
||||
'role_all' => 'Все',
|
||||
'role_own' => 'Власне',
|
||||
'role_controlled_by_asset' => 'Контролюється за об\'єктом, до якого вони завантажуються',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => 'Не вдалося завантажити файл. Сервер може не приймати файли такого розміру.',
|
||||
|
||||
'zip_file' => 'Поле :attribute повинне вказувати файл в ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => 'Файл :attribute не повинен перевищувати :size МБ.',
|
||||
'zip_file_mime' => 'Поле :attribute повинне посилатись на файл типу :validtypes, знайдений :foundType.',
|
||||
'zip_model_expected' => 'Очікувався об’єкт даних, але знайдено ":type".',
|
||||
'zip_unique' => 'Поле :attribute має бути унікальним для типу об\'єкта в ZIP.',
|
||||
|
||||
@@ -91,7 +91,7 @@ return [
|
||||
'mfa_option_totp_title' => 'Mobil ilova',
|
||||
'mfa_option_totp_desc' => 'Ko‘p faktorli autentifikatsiyadan foydalanish uchun sizga Google Authenticator, Authy yoki Microsoft Authenticator kabi OTPni qo‘llab-quvvatlaydigan mobil ilova kerak bo‘ladi.',
|
||||
'mfa_option_backup_codes_title' => 'Zaxira kodlari',
|
||||
'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.',
|
||||
'mfa_option_backup_codes_desc' => 'Shaxsingizni tasdiqlash uchun tizimga kirishda kiritadigan bir martalik zaxira kodlari to\'plamini yaratadi. Bularni xavfsiz va ishonchli joyda saqlang.',
|
||||
'mfa_gen_confirm_and_enable' => 'Tasdiqlash va yoqish',
|
||||
'mfa_gen_backup_codes_title' => 'Zaxira kodlarini sozlash',
|
||||
'mfa_gen_backup_codes_desc' => 'Quyidagi kodlar ro‘yxatini xavfsiz joyda saqlang. Tizimga kirishda siz kodlardan birini ikkinchi autentifikatsiya mexanizmi sifatida ishlatishingiz mumkin.',
|
||||
|
||||
@@ -105,11 +105,11 @@ return [
|
||||
'url' => ':attribute URL formatida emas.',
|
||||
'uploaded' => 'Faylni yuklashda xatolik. Server bunday hajmdagi faylllarni yuklamasligi mumkin.',
|
||||
|
||||
'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.',
|
||||
'zip_model_expected' => 'Data object expected but ":type" found.',
|
||||
'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
|
||||
'zip_file' => ':attribute ZIP ichidagi faylga havola qilishi kerak.',
|
||||
'zip_file_size' => ':attribute fayli :size MB dan oshmasligi kerak.',
|
||||
'zip_file_mime' => ':attribute :validTypes turidagi faylga havola qilishi kerak, lekin :foundType turida keldi.',
|
||||
'zip_model_expected' => 'Ma\'lumotlar obyekti kutilmoqda, ammo ":type" topildi.',
|
||||
'zip_unique' => ':attribute ZIP ichidagi obyekt turi uchun noyob bo\'lishi kerak.',
|
||||
|
||||
// Custom validation lines
|
||||
'custom' => [
|
||||
|
||||
@@ -28,22 +28,22 @@ return [
|
||||
'chapter_move_notification' => '章节移动成功',
|
||||
|
||||
// Books
|
||||
'book_create' => '图书已创建',
|
||||
'book_create_notification' => '成功创建图书',
|
||||
'book_create_from_chapter' => '将章节转换为图书',
|
||||
'book_create_from_chapter_notification' => '章节已成功转换为图书',
|
||||
'book_update' => '图书已更新',
|
||||
'book_update_notification' => '图书更新成功',
|
||||
'book_delete' => '图书已删除',
|
||||
'book_delete_notification' => '图书删除成功',
|
||||
'book_sort' => '图书已排序',
|
||||
'book_sort_notification' => '图书重新排序成功',
|
||||
'book_create' => '书籍已创建',
|
||||
'book_create_notification' => '成功创建书籍',
|
||||
'book_create_from_chapter' => '将章节转换为书籍',
|
||||
'book_create_from_chapter_notification' => '章节已成功转换为书籍',
|
||||
'book_update' => '书籍已更新',
|
||||
'book_update_notification' => '书籍更新成功',
|
||||
'book_delete' => '书籍已删除',
|
||||
'book_delete_notification' => '书籍删除成功',
|
||||
'book_sort' => '书籍已排序',
|
||||
'book_sort_notification' => '书籍重新排序成功',
|
||||
|
||||
// Bookshelves
|
||||
'bookshelf_create' => '书架已创建',
|
||||
'bookshelf_create_notification' => '书架创建成功',
|
||||
'bookshelf_create_from_book' => '将图书转换为书架',
|
||||
'bookshelf_create_from_book_notification' => '图书已成功转换为书架',
|
||||
'bookshelf_create_from_book' => '将书籍转换为书架',
|
||||
'bookshelf_create_from_book_notification' => '书籍已成功转换为书架',
|
||||
'bookshelf_update' => '书架已更新',
|
||||
'bookshelf_update_notification' => '书架更新成功',
|
||||
'bookshelf_delete' => '书架已删除',
|
||||
|
||||
@@ -121,79 +121,79 @@ return [
|
||||
'shelves_save' => '保存书架',
|
||||
'shelves_books' => '书籍已在此书架里',
|
||||
'shelves_add_books' => '将书籍加入此书架',
|
||||
'shelves_drag_books' => '拖动下面的图书将其添加到此书架',
|
||||
'shelves_empty_contents' => '这个书架没有分配图书',
|
||||
'shelves_edit_and_assign' => '编辑书架以分配图书',
|
||||
'shelves_drag_books' => '拖动下面的书籍将其添加到此书架',
|
||||
'shelves_empty_contents' => '这个书架没有分配书籍',
|
||||
'shelves_edit_and_assign' => '编辑书架以分配书籍',
|
||||
'shelves_edit_named' => '编辑书架 :name',
|
||||
'shelves_edit' => '编辑书架',
|
||||
'shelves_delete' => '删除书架',
|
||||
'shelves_delete_named' => '删除书架 :name',
|
||||
'shelves_delete_explain' => "此操作将删除书架 ”:name”。书架中的图书不会被删除。",
|
||||
'shelves_delete_explain' => "此操作将删除书架 ”:name”。书架中的书籍不会被删除。",
|
||||
'shelves_delete_confirmation' => '您确定要删除此书架吗?',
|
||||
'shelves_permissions' => '书架权限',
|
||||
'shelves_permissions_updated' => '书架权限已更新',
|
||||
'shelves_permissions_active' => '书架权限已启用',
|
||||
'shelves_permissions_cascade_warning' => '书架上的权限不会自动应用到书架里的图书上,这是因为图书可以在多个书架上存在。使用下面的选项可以将权限复制到书架里的图书上。',
|
||||
'shelves_permissions_create' => '书架创建权限仅用于使用下面的操作将权限复制到子图书。这个权限不是用来控制创建书籍的。',
|
||||
'shelves_copy_permissions_to_books' => '将权限复制到图书',
|
||||
'shelves_permissions_cascade_warning' => '书架上的权限不会自动应用到书架里的书籍上,这是因为书籍可以在多个书架上存在。使用下面的选项可以将权限复制到书架里的书籍上。',
|
||||
'shelves_permissions_create' => '书架创建权限仅用于使用下面的操作将权限复制到子书籍。这个权限不是用来控制创建书籍的。',
|
||||
'shelves_copy_permissions_to_books' => '将权限复制到书籍',
|
||||
'shelves_copy_permissions' => '复制权限',
|
||||
'shelves_copy_permissions_explain' => '此操作会将此书架的当前权限设置应用于其中包含的所有图书上。 启用前请确保已保存对此书架权限的任何更改。',
|
||||
'shelves_copy_permission_success' => '书架权限已复制到 :count 本图书上',
|
||||
'shelves_copy_permissions_explain' => '此操作会将此书架的当前权限设置应用于其中包含的所有书籍上。 启用前请确保已保存对此书架权限的任何更改。',
|
||||
'shelves_copy_permission_success' => '书架权限已复制到 :count 本书籍上',
|
||||
|
||||
// Books
|
||||
'book' => '图书',
|
||||
'books' => '图书',
|
||||
'book' => '书籍',
|
||||
'books' => '书籍',
|
||||
'x_books' => ':count 本书',
|
||||
'books_empty' => '不存在已创建的书',
|
||||
'books_popular' => '热门图书',
|
||||
'books_popular' => '热门书籍',
|
||||
'books_recent' => '最近的书',
|
||||
'books_new' => '新书',
|
||||
'books_new_action' => '新书',
|
||||
'books_popular_empty' => '最受欢迎的图书将出现在这里。',
|
||||
'books_new_empty' => '最近创建的图书将出现在这里。',
|
||||
'books_create' => '创建图书',
|
||||
'books_delete' => '删除图书',
|
||||
'books_delete_named' => '删除图书「:bookName」',
|
||||
'books_delete_explain' => '此操作将删除图书 “:bookName”。图书中的所有的章节和页面都会被删除。',
|
||||
'books_delete_confirmation' => '您确定要删除此图书吗?',
|
||||
'books_edit' => '编辑图书',
|
||||
'books_edit_named' => '编辑图书「:bookName」',
|
||||
'books_popular_empty' => '最受欢迎的书籍将出现在这里。',
|
||||
'books_new_empty' => '最近创建的书籍将出现在这里。',
|
||||
'books_create' => '创建书籍',
|
||||
'books_delete' => '删除书籍',
|
||||
'books_delete_named' => '删除书籍「:bookName」',
|
||||
'books_delete_explain' => '此操作将删除书籍 “:bookName”。书籍中的所有的章节和页面都会被删除。',
|
||||
'books_delete_confirmation' => '您确定要删除此书籍吗?',
|
||||
'books_edit' => '编辑书籍',
|
||||
'books_edit_named' => '编辑书籍「:bookName」',
|
||||
'books_form_book_name' => '书名',
|
||||
'books_save' => '保存图书',
|
||||
'books_permissions' => '图书权限',
|
||||
'books_permissions_updated' => '图书权限已更新',
|
||||
'books_save' => '保存书籍',
|
||||
'books_permissions' => '书籍权限',
|
||||
'books_permissions_updated' => '书籍权限已更新',
|
||||
'books_empty_contents' => '本书目前没有页面或章节。',
|
||||
'books_empty_create_page' => '创建页面',
|
||||
'books_empty_sort_current_book' => '排序当前图书',
|
||||
'books_empty_sort_current_book' => '排序当前书籍',
|
||||
'books_empty_add_chapter' => '添加章节',
|
||||
'books_permissions_active' => '图书权限已启用',
|
||||
'books_permissions_active' => '书籍权限已启用',
|
||||
'books_search_this' => '搜索这本书',
|
||||
'books_navigation' => '图书导航',
|
||||
'books_sort' => '排序图书内容',
|
||||
'books_navigation' => '书籍导航',
|
||||
'books_sort' => '排序书籍内容',
|
||||
'books_sort_desc' => '在书籍内部移动章节与页面以重组内容;支持添加其他书籍,实现跨书籍便捷移动章节与页面;还可设置自动排序规则,在内容发生变更时自动对本书内容进行排序。',
|
||||
'books_sort_auto_sort' => '自动排序选项',
|
||||
'books_sort_auto_sort_active' => '自动排序已激活:::sortName',
|
||||
'books_sort_named' => '排序图书「:bookName」',
|
||||
'books_sort_named' => '排序书籍「:bookName」',
|
||||
'books_sort_name' => '按名称排序',
|
||||
'books_sort_created' => '创建时间排序',
|
||||
'books_sort_updated' => '按更新时间排序',
|
||||
'books_sort_chapters_first' => '章节正序',
|
||||
'books_sort_chapters_last' => '章节倒序',
|
||||
'books_sort_show_other' => '显示其他图书',
|
||||
'books_sort_show_other' => '显示其他书籍',
|
||||
'books_sort_save' => '保存新顺序',
|
||||
'books_sort_show_other_desc' => '在此添加其他图书进入排序界面,这样就可以轻松跨图书重新排序。',
|
||||
'books_sort_show_other_desc' => '在此添加其他书籍进入排序界面,这样就可以轻松跨书籍重新排序。',
|
||||
'books_sort_move_up' => '上移',
|
||||
'books_sort_move_down' => '下移',
|
||||
'books_sort_move_prev_book' => '移动到上一图书',
|
||||
'books_sort_move_next_book' => '移动到下一图书',
|
||||
'books_sort_move_prev_book' => '移动到上一书籍',
|
||||
'books_sort_move_next_book' => '移动到下一书籍',
|
||||
'books_sort_move_prev_chapter' => '移动到上一章节',
|
||||
'books_sort_move_next_chapter' => '移动到下一章节',
|
||||
'books_sort_move_book_start' => '移动到图书开头',
|
||||
'books_sort_move_book_end' => '移动到图书结尾',
|
||||
'books_sort_move_book_start' => '移动到书籍开头',
|
||||
'books_sort_move_book_end' => '移动到书籍结尾',
|
||||
'books_sort_move_before_chapter' => '移动到章节前',
|
||||
'books_sort_move_after_chapter' => '移至章节后',
|
||||
'books_copy' => '复制图书',
|
||||
'books_copy_success' => '图书已成功复制',
|
||||
'books_copy' => '复制书籍',
|
||||
'books_copy_success' => '书籍已成功复制',
|
||||
|
||||
// Chapters
|
||||
'chapter' => '章节',
|
||||
@@ -218,7 +218,7 @@ return [
|
||||
'chapters_permissions_active' => '章节权限已启用',
|
||||
'chapters_permissions_success' => '章节权限已更新',
|
||||
'chapters_search_this' => '从本章节搜索',
|
||||
'chapter_sort_book' => '排序图书',
|
||||
'chapter_sort_book' => '排序书籍',
|
||||
|
||||
// Pages
|
||||
'page' => '页面',
|
||||
@@ -332,7 +332,7 @@ return [
|
||||
'toggle_sidebar' => '切换侧边栏',
|
||||
'page_tags' => '页面标签',
|
||||
'chapter_tags' => '章节标签',
|
||||
'book_tags' => '图书标签',
|
||||
'book_tags' => '书籍标签',
|
||||
'shelf_tags' => '书架标签',
|
||||
'tag' => '标签',
|
||||
'tags' => '标签',
|
||||
@@ -345,13 +345,13 @@ return [
|
||||
'tags_usages' => '标签总使用量',
|
||||
'tags_assigned_pages' => '有这个标签的页面',
|
||||
'tags_assigned_chapters' => '有这个标签的章节',
|
||||
'tags_assigned_books' => '有这个标签的图书',
|
||||
'tags_assigned_books' => '有这个标签的书籍',
|
||||
'tags_assigned_shelves' => '有这个标签的书架',
|
||||
'tags_x_unique_values' => ':count 个不重复项目',
|
||||
'tags_all_values' => '所有值',
|
||||
'tags_view_tags' => '查看标签',
|
||||
'tags_view_existing_tags' => '查看已有的标签',
|
||||
'tags_list_empty_hint' => '您可以在页面编辑器的侧边栏添加标签,或者在编辑图书、章节、书架时添加。',
|
||||
'tags_list_empty_hint' => '您可以在页面编辑器的侧边栏添加标签,或者在编辑书籍、章节、书架时添加。',
|
||||
'attachments' => '附件',
|
||||
'attachments_explain' => '上传一些文件或附加一些链接显示在您的网页上。这些在页面的侧边栏中可见。',
|
||||
'attachments_explain_instant_save' => '这里的更改将立即保存。',
|
||||
@@ -390,7 +390,7 @@ return [
|
||||
'profile_created_content' => '已创建内容',
|
||||
'profile_not_created_pages' => ':userName尚未创建任何页面',
|
||||
'profile_not_created_chapters' => ':userName尚未创建任何章节',
|
||||
'profile_not_created_books' => ':userName尚未创建任何图书',
|
||||
'profile_not_created_books' => ':userName尚未创建任何书籍',
|
||||
'profile_not_created_shelves' => ':userName 尚未创建任何书架',
|
||||
|
||||
// Comments
|
||||
@@ -435,13 +435,13 @@ return [
|
||||
|
||||
// Conversions
|
||||
'convert_to_shelf' => '转换为书架',
|
||||
'convert_to_shelf_contents_desc' => '你可以将这本书转换为具有相同内容的新书架。本书中的章节将被转换为图书。如果这本书包含有任何不在章节分类中的页面,那么将会有一本单独的图书包含这些页面,这本书也将成为新书架的一部分。',
|
||||
'convert_to_shelf_permissions_desc' => '在这本书上设置的任何权限都将复制到所有未强制执行权限的新书架和新子图书上。请注意,书架上的权限不会像图书那样继承到内容物上。',
|
||||
'convert_book' => '转换图书',
|
||||
'convert_book_confirm' => '您确定要转换此图书吗?',
|
||||
'convert_to_shelf_contents_desc' => '你可以将这本书转换为具有相同内容的新书架。本书中的章节将被转换为书籍。如果这本书包含有任何不在章节分类中的页面,那么将会有一本单独的书籍包含这些页面,这本书也将成为新书架的一部分。',
|
||||
'convert_to_shelf_permissions_desc' => '在这本书上设置的任何权限都将复制到所有未强制执行权限的新书架和新子书籍上。请注意,书架上的权限不会像书籍那样继承到内容物上。',
|
||||
'convert_book' => '转换书籍',
|
||||
'convert_book_confirm' => '您确定要转换此书籍吗?',
|
||||
'convert_undo_warning' => '这可不能轻易撤消。',
|
||||
'convert_to_book' => '转换为图书',
|
||||
'convert_to_book_desc' => '您可以将此章节转换为具有相同内容的新图书。此章节中设置的任何权限都将复制到新图书上,但从父图书继承的任何权限都不会被复制,这可能会导致访问控制发生变化。',
|
||||
'convert_to_book' => '转换为书籍',
|
||||
'convert_to_book_desc' => '您可以将此章节转换为具有相同内容的新书籍。此章节中设置的任何权限都将复制到新书籍上,但从父书籍继承的任何权限都不会被复制,这可能会导致访问控制发生变化。',
|
||||
'convert_chapter' => '转换章节',
|
||||
'convert_chapter_confirm' => '您确定要转换此章节吗?',
|
||||
|
||||
@@ -469,8 +469,8 @@ return [
|
||||
'watch_detail_new' => '已关注新页面',
|
||||
'watch_detail_updates' => '已关注新页面和更新',
|
||||
'watch_detail_comments' => '已关注新页面、更新和评论',
|
||||
'watch_detail_parent_book' => '已关注—继承自父图书',
|
||||
'watch_detail_parent_book_ignore' => '已忽略—继承自父图书',
|
||||
'watch_detail_parent_book' => '已关注—继承自父书籍',
|
||||
'watch_detail_parent_book_ignore' => '已忽略—继承自父书籍',
|
||||
'watch_detail_parent_chapter' => '已关注—继承自父章节',
|
||||
'watch_detail_parent_chapter_ignore' => '已忽略—继承自父章节',
|
||||
];
|
||||
|
||||
@@ -68,11 +68,11 @@ return [
|
||||
// Entities
|
||||
'entity_not_found' => '未找到项目',
|
||||
'bookshelf_not_found' => '未找到书架',
|
||||
'book_not_found' => '未找到图书',
|
||||
'book_not_found' => '未找到书籍',
|
||||
'page_not_found' => '未找到页面',
|
||||
'chapter_not_found' => '未找到章节',
|
||||
'selected_book_not_found' => '选中的书未找到',
|
||||
'selected_book_chapter_not_found' => '未找到所选的图书或章节',
|
||||
'selected_book_chapter_not_found' => '未找到所选的书籍或章节',
|
||||
'guests_cannot_save_drafts' => '访客不能保存草稿',
|
||||
|
||||
// Users
|
||||
|
||||
@@ -55,7 +55,7 @@ return [
|
||||
'link_color' => '默认链接颜色',
|
||||
'content_colors_desc' => '为页面组织层次结构中的所有元素设置颜色。为了便于阅读,建议选择与默认颜色亮度相似的颜色。',
|
||||
'bookshelf_color' => '书架颜色',
|
||||
'book_color' => '图书颜色',
|
||||
'book_color' => '书籍颜色',
|
||||
'chapter_color' => '章节颜色',
|
||||
'page_color' => '页面颜色',
|
||||
'page_draft_color' => '页面草稿颜色',
|
||||
@@ -188,8 +188,8 @@ return [
|
||||
'role_system' => '系统权限',
|
||||
'role_manage_users' => '管理用户',
|
||||
'role_manage_roles' => '管理角色与角色权限',
|
||||
'role_manage_entity_permissions' => '管理所有图书、章节和页面的权限',
|
||||
'role_manage_own_entity_permissions' => '管理自己的图书、章节和页面的权限',
|
||||
'role_manage_entity_permissions' => '管理所有书籍、章节和页面的权限',
|
||||
'role_manage_own_entity_permissions' => '管理自己的书籍、章节和页面的权限',
|
||||
'role_manage_page_templates' => '管理页面模板',
|
||||
'role_access_api' => '访问系统 API',
|
||||
'role_manage_settings' => '管理 App 设置',
|
||||
|
||||
@@ -109,7 +109,7 @@ return [
|
||||
'import_zip_cant_read' => '無法讀取 ZIP 檔案。',
|
||||
'import_zip_cant_decode_data' => '無法尋找並解碼 ZIP data.json 內容。',
|
||||
'import_zip_no_data' => 'ZIP 檔案資料沒有預期的書本、章節或頁面內容。',
|
||||
'import_zip_data_too_large' => 'ZIP data.json content exceeds the configured application maximum upload size.',
|
||||
'import_zip_data_too_large' => 'ZIP 檔案 data.json 的內容超過了設定的應用程式最大上傳大小。',
|
||||
'import_validation_failed' => '匯入 ZIP 驗證失敗,發生錯誤:',
|
||||
'import_zip_failed_notification' => '匯入 ZIP 檔案失敗。',
|
||||
'import_perms_books' => '您缺乏建立書本所需的權限。',
|
||||
|
||||
@@ -11,8 +11,8 @@ return [
|
||||
'updated_page_subject' => '頁面更新::pageName',
|
||||
'updated_page_intro' => ':appName: 中的一個頁面已被更新',
|
||||
'updated_page_debounce' => '為了防止出現大量通知,一段時間內您不會收到同一編輯者再次編輯本頁面的通知。',
|
||||
'comment_mention_subject' => 'You have been mentioned in a comment on page: :pageName',
|
||||
'comment_mention_intro' => 'You were mentioned in a comment on :appName:',
|
||||
'comment_mention_subject' => '您在以下頁面的留言中被提及::pageName',
|
||||
'comment_mention_intro' => '您再在 :appName: 的留言中被提及',
|
||||
|
||||
'detail_page_name' => '頁面名稱:',
|
||||
'detail_page_path' => '頁面路徑:',
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
'notifications_desc' => '控制在系統有特定活動時,是否要接收電子郵件通知',
|
||||
'notifications_opt_own_page_changes' => '當我的頁面有異動時發送通知',
|
||||
'notifications_opt_own_page_comments' => '當我的頁面有評論時發送通知',
|
||||
'notifications_opt_comment_mentions' => 'Notify when I\'m mentioned in a comment',
|
||||
'notifications_opt_comment_mentions' => '當我在留言中被提及時通知我',
|
||||
'notifications_opt_comment_replies' => '當我的評論有新的回覆時發送通知',
|
||||
'notifications_save' => '儲存偏好設定',
|
||||
'notifications_update_success' => '通知設定已更新',
|
||||
|
||||
@@ -75,8 +75,8 @@ return [
|
||||
'reg_confirm_restrict_domain_placeholder' => '尚未設定限制',
|
||||
|
||||
// Sorting Settings
|
||||
'sorting' => 'Lists & Sorting',
|
||||
'sorting_book_default' => 'Default Book Sort Rule',
|
||||
'sorting' => '清單與排序',
|
||||
'sorting_book_default' => '預設書籍排序規則',
|
||||
'sorting_book_default_desc' => '選取要套用至新書籍的預設排序規則。這不會影響現有書籍,並可按書籍覆寫。',
|
||||
'sorting_rules' => '排序規則',
|
||||
'sorting_rules_desc' => '這些是預先定義的排序作業,可套用於系統中的內容。',
|
||||
@@ -103,8 +103,8 @@ return [
|
||||
'sort_rule_op_updated_date' => '更新日期',
|
||||
'sort_rule_op_chapters_first' => '第一章',
|
||||
'sort_rule_op_chapters_last' => '最後一章',
|
||||
'sorting_page_limits' => 'Per-Page Display Limits',
|
||||
'sorting_page_limits_desc' => 'Set how many items to show per-page in various lists within the system. Typically a lower amount will be more performant, while a higher amount avoids the need to click through multiple pages. Using an even multiple of 3 (18, 24, 30, etc...) is recommended.',
|
||||
'sorting_page_limits' => '每頁顯示限制',
|
||||
'sorting_page_limits_desc' => '設定系統內各類清單每頁顯示的項目數量。通常較低的數量能提升效能表現,而較高的數量則可避免使用者需點擊翻閱多頁。建議採用 3 的整數倍數(如 18、24、30 等)。',
|
||||
|
||||
// Maintenance settings
|
||||
'maint' => '維護',
|
||||
@@ -198,13 +198,13 @@ return [
|
||||
'role_import_content' => '匯入內容',
|
||||
'role_editor_change' => '重設頁面編輯器',
|
||||
'role_notifications' => '管理和接收通知',
|
||||
'role_permission_note_users_and_roles' => 'These permissions will technically also provide visibility & searching of users & roles in the system.',
|
||||
'role_permission_note_users_and_roles' => '這些權限在技術上亦將提供系統內使用者與角色的能見度及搜尋功能。',
|
||||
'role_asset' => '資源權限',
|
||||
'roles_system_warning' => '請注意,有上述三項權限中的任一項的使用者都可以更改自己或系統中其他人的權限。有這些權限的角色只應分配給受信任的使用者。',
|
||||
'role_asset_desc' => '對系統內資源的預設權限將由這裡的權限控制。若有單獨設定在書本、章節和頁面上的權限,將會覆寫這裡的權限設定。',
|
||||
'role_asset_admins' => '管理員會自動取得對所有內容的存取權,但這些選項可能會顯示或隱藏使用者介面的選項。',
|
||||
'role_asset_image_view_note' => '這與圖像管理器中的可見性有關。已經上傳的圖片的實際訪問取決於系統圖像存儲選項。',
|
||||
'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.',
|
||||
'role_asset_users_note' => '這些權限在技術上亦將提供系統內使用者的能見度及搜尋功能。',
|
||||
'role_all' => '全部',
|
||||
'role_own' => '擁有',
|
||||
'role_controlled_by_asset' => '依據隸屬的資源來決定',
|
||||
|
||||
@@ -106,7 +106,7 @@ return [
|
||||
'uploaded' => '無法上傳文檔案, 伺服器可能不接受此大小的檔案。',
|
||||
|
||||
'zip_file' => ':attribute 需要參照 ZIP 中的檔案。',
|
||||
'zip_file_size' => 'The file :attribute must not exceed :size MB.',
|
||||
'zip_file_size' => '檔案 :attribute 不能超過 :size MB。',
|
||||
'zip_file_mime' => ':attribute 需要參照類型為 :validTypes 的檔案,找到 :foundType。',
|
||||
'zip_model_expected' => '預期為資料物件,但找到「:type」。',
|
||||
'zip_unique' => '對於 ZIP 中的物件類型,:attribute 必須是唯一的。',
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -23,7 +23,6 @@
|
||||
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"eventsource-client": "^1.1.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
@@ -4798,27 +4797,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-client": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-client/-/eventsource-client-1.2.0.tgz",
|
||||
"integrity": "sha512-kDI75RSzO3TwyG/K9w1ap8XwqSPcwi6jaMkNulfVeZmSeUM49U8kUzk1s+vKNt0tGrXgK47i+620Yasn1ccFiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"eventsource-client": "^1.1.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
|
||||
@@ -56,6 +56,11 @@ Big thanks to these companies for supporting the project.
|
||||
<img width="480" src="https://www.bookstackapp.com/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.onyx.app/?utm_source=bookstack" target="_blank">
|
||||
<img width="420" src="https://www.bookstackapp.com/images/sponsors/onyx.png" alt="onyx.app">
|
||||
</a></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
||||
#### Bronze Sponsors
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user