diff --git a/ServiceAPI/Notes.php b/ServiceAPI/Notes.php
index 67e78dfdd..0e91ad766 100644
--- a/ServiceAPI/Notes.php
+++ b/ServiceAPI/Notes.php
@@ -36,7 +36,7 @@ public function getNote(int $noteId, callable $resolve, callable $reject): void
$resolve([
"title" => $note->getName(),
"link" => "/note" . $note->getPrettyId(),
- "html" => $note->getText(),
+ "html" => $note->getText($this->user),
"created" => (string) $note->getPublicationTime(),
"author" => [
"name" => $noteOwner->getCanonicalName(),
diff --git a/VKAPI/Handlers/Notes.php b/VKAPI/Handlers/Notes.php
index 8f8314376..fcf604506 100644
--- a/VKAPI/Handlers/Notes.php
+++ b/VKAPI/Handlers/Notes.php
@@ -9,7 +9,7 @@
use openvk\Web\Models\Repositories\Comments as CommentsRepo;
use openvk\Web\Models\Repositories\Photos as PhotosRepo;
use openvk\Web\Models\Repositories\Videos as VideosRepo;
-use openvk\Web\Models\Entities\{Note, Comment};
+use openvk\Web\Models\Entities\{Note, Comment, User};
final class Notes extends VKAPIRequestHandler
{
@@ -45,24 +45,9 @@ public function createComment(int $note_id, int $owner_id, string $message, stri
}
$note = (new NotesRepo())->getNoteById($owner_id, $note_id);
+ $this->assertNoteAccessible($note);
- if (!$note) {
- $this->fail(15, "Access denied");
- }
-
- if ($note->isDeleted()) {
- $this->fail(15, "Access denied");
- }
-
- if ($note->getOwner()->isDeleted()) {
- $this->fail(15, "Access denied");
- }
-
- if (!$note->canBeViewedBy($this->getUser())) {
- $this->fail(15, "Access denied");
- }
-
- if (!$note->getOwner()->getPrivacyPermission('notes.read', $this->getUser())) {
+ if (!$note->canBeCommentedBy($this->getUser())) {
$this->fail(15, "Access denied");
}
@@ -186,26 +171,7 @@ public function getById(int $note_id, int $owner_id, bool $need_wiki = false)
$this->requireUser();
$note = (new NotesRepo())->getNoteById($owner_id, $note_id);
-
- if (!$note) {
- $this->fail(15, "Access denied");
- }
-
- if ($note->isDeleted()) {
- $this->fail(15, "Access denied");
- }
-
- if (!$note->getOwner() || $note->getOwner()->isDeleted()) {
- $this->fail(15, "Access denied");
- }
-
- if (!$note->getOwner()->getPrivacyPermission('notes.read', $this->getUser())) {
- $this->fail(15, "Access denied");
- }
-
- if (!$note->canBeViewedBy($this->getUser())) {
- $this->fail(15, "Access denied");
- }
+ $this->assertNoteAccessible($note);
return $note->toVkApiStruct();
}
@@ -215,36 +181,43 @@ public function getComments(int $note_id, int $owner_id, int $sort = 1, int $off
$this->requireUser();
$note = (new NotesRepo())->getNoteById($owner_id, $note_id);
+ $this->assertNoteAccessible($note);
- if (!$note) {
- $this->fail(15, "Access denied");
- }
+ $arr = (object) [
+ "count" => $note->getCommentsCount(),
+ "items" => []];
+ $comments = array_slice(iterator_to_array($note->getComments(1, $count + $offset)), $offset);
- if ($note->isDeleted()) {
- $this->fail(15, "Access denied");
+ foreach ($comments as $comment) {
+ $arr->items[] = $comment->toVkApiStruct($this->getUser(), false, false, $note);
}
- if (!$note->getOwner()) {
- $this->fail(15, "Access denied");
- }
+ return $arr;
+ }
- if (!$note->getOwner()->getPrivacyPermission('notes.read', $this->getUser())) {
+ private function assertNoteAccessible(?Note $note): void
+ {
+ if (!$note || $note->isDeleted()) {
$this->fail(15, "Access denied");
}
- if (!$note->canBeViewedBy($this->getUser())) {
+ $owner = $note->getOwner();
+ if (!$owner) {
$this->fail(15, "Access denied");
}
- $arr = (object) [
- "count" => $note->getCommentsCount(),
- "items" => []];
- $comments = array_slice(iterator_to_array($note->getComments(1, $count + $offset)), $offset);
+ if ($owner instanceof User) {
+ if ($owner->isDeleted()) {
+ $this->fail(15, "Access denied");
+ }
- foreach ($comments as $comment) {
- $arr->items[] = $comment->toVkApiStruct($this->getUser(), false, false, $note);
+ if (!$owner->getPrivacyPermission("notes.read", $this->getUser())) {
+ $this->fail(15, "Access denied");
+ }
}
- return $arr;
+ if (!$note->canBeViewedBy($this->getUser())) {
+ $this->fail(15, "Access denied");
+ }
}
}
diff --git a/Web/Models/Entities/Club.php b/Web/Models/Entities/Club.php
index 0eb2db4ab..198e61bf1 100644
--- a/Web/Models/Entities/Club.php
+++ b/Web/Models/Entities/Club.php
@@ -37,6 +37,10 @@ class Club extends RowModel
public const WALL_OPEN = 1;
public const WALL_LIMITED = 2;
+ public const PAGES_DISABLED = 0;
+ public const PAGES_OPEN = 1;
+ public const PAGES_LIMITED = 2;
+
public function getId(): int
{
return $this->getRecord()->id;
@@ -524,6 +528,51 @@ public function canUploadDocs(?User $user): bool
return $this->canBeModifiedBy($user);
}
+ public function getPagesType(): int
+ {
+ return (int) ($this->getRecord()->pages ?? 0);
+ }
+
+ public function isPagesEnabled(): bool
+ {
+ return $this->getPagesType() !== self::PAGES_DISABLED;
+ }
+
+ public function setPages(int $type): void
+ {
+ if ($type > 2 || $type < 0) {
+ throw new \LogicException("Invalid pages");
+ }
+
+ $this->stateChanges("pages", $type);
+ }
+
+ public function canManagePages(?User $user): bool
+ {
+ if (!$user) {
+ return false;
+ }
+
+ return $this->isPagesEnabled() && $this->canBeModifiedBy($user);
+ }
+
+ public function canCreatePages(?User $user): bool
+ {
+ if (!$user || !$this->isPagesEnabled()) {
+ return false;
+ }
+
+ if ($this->canBeModifiedBy($user)) {
+ return true;
+ }
+
+ if ($this->getPagesType() === self::PAGES_OPEN) {
+ return (bool) $this->getSubscriptionStatus($user);
+ }
+
+ return false;
+ }
+
public function getAudiosCollectionSize()
{
return (new \openvk\Web\Models\Repositories\Audios())->getClubCollectionSize($this);
diff --git a/Web/Models/Entities/Note.php b/Web/Models/Entities/Note.php
index b9829822d..7fab7e080 100644
--- a/Web/Models/Entities/Note.php
+++ b/Web/Models/Entities/Note.php
@@ -7,6 +7,10 @@
use HTMLPurifier_Config;
use HTMLPurifier;
use HTMLPurifier_Filter;
+use Parsedown;
+use Chandler\Database\DatabaseConnection;
+use openvk\Web\Models\RowModel;
+use openvk\Web\Models\Repositories\{Users, Clubs, Notes, Photos};
class SecurityFilter extends HTMLPurifier_Filter
{
@@ -19,13 +23,11 @@ function ($matches) {
$src = $originalSrc;
if (OPENVK_ROOT_CONF["openvk"]["preferences"]["notes"]["disableHotlinking"] ?? true) {
- if (!str_contains($src, "/image.php?url=")) {
+ $path = parse_url($src, PHP_URL_PATH) ?? $src;
+ $isPhotoId = (bool) preg_match('#(?:^|/)photo-?\d+_\d+$#i', $path);
+ if (!$isPhotoId && !str_contains($src, "/image.php?url=")) {
$src = '/image.php?url=' . base64_encode($originalSrc);
- } /*else {
- $src = preg_replace_callback('/(.*)\/image\.php\?url=(.*)/i', function ($matches) {
- return base64_decode($matches[2]);
- }, $src);
- }*/
+ }
}
return str_replace($originalSrc, $src, $matches[0]);
@@ -41,6 +43,186 @@ class Note extends Postable
{
protected $tableName = "notes";
+ public const FORMAT_HTML = 0;
+ public const FORMAT_MARKDOWN = 1;
+
+ public const ACCESS_EVERYONE = 0;
+ public const ACCESS_MEMBERS = 1;
+ public const ACCESS_ADMINS = 2;
+
+ private ?int $revisionEditorId = null;
+
+ public function getOwnerId(): int
+ {
+ if ($this->getRecord()) {
+ return (int) $this->getRecord()->owner;
+ }
+
+ return (int) ($this->changes["owner"] ?? 0);
+ }
+
+ public function getOwner(bool $real = false): RowModel
+ {
+ $oid = $this->getOwnerId();
+ if ($oid < 0) {
+ return (new Clubs())->get(abs($oid));
+ }
+
+ return (new Users())->get(abs($oid));
+ }
+
+ public function isClubNote(): bool
+ {
+ return $this->getOwnerId() < 0;
+ }
+
+ public function getClub(): ?Club
+ {
+ return $this->isClubNote() ? (new Clubs())->get(abs($this->getOwnerId())) : null;
+ }
+
+ public function getCreatedBy(): ?User
+ {
+ $record = $this->getRecord();
+ $createdBy = $record ? ($record->created_by ?? null) : ($this->changes["created_by"] ?? null);
+ if ($createdBy) {
+ return (new Users())->get((int) $createdBy);
+ }
+
+ if (!$this->isClubNote()) {
+ return (new Users())->get(abs($this->getOwnerId()));
+ }
+
+ return null;
+ }
+
+ public function getFormat(): int
+ {
+ $record = $this->getRecord();
+ if ($record && isset($record->format)) {
+ return (int) $record->format;
+ }
+
+ return (int) ($this->changes["format"] ?? self::FORMAT_MARKDOWN);
+ }
+
+ public function isMarkdown(): bool
+ {
+ return $this->getFormat() === self::FORMAT_MARKDOWN;
+ }
+
+ public function isMain(): bool
+ {
+ $record = $this->getRecord();
+ if ($record && isset($record->is_main)) {
+ return (bool) $record->is_main;
+ }
+
+ return (bool) ($this->changes["is_main"] ?? false);
+ }
+
+ public function getViewAccess(): int
+ {
+ $record = $this->getRecord();
+ if ($record && isset($record->view_access)) {
+ return (int) $record->view_access;
+ }
+
+ return (int) ($this->changes["view_access"] ?? self::ACCESS_EVERYONE);
+ }
+
+ public function getEditAccess(): int
+ {
+ $record = $this->getRecord();
+ if ($record && isset($record->edit_access)) {
+ return (int) $record->edit_access;
+ }
+
+ return (int) ($this->changes["edit_access"] ?? self::ACCESS_ADMINS);
+ }
+
+ public function getCommentAccess(): int
+ {
+ $record = $this->getRecord();
+ if ($record && isset($record->comment_access)) {
+ return (int) $record->comment_access;
+ }
+
+ return (int) ($this->changes["comment_access"] ?? self::ACCESS_EVERYONE);
+ }
+
+ public function keepsRevisions(): bool
+ {
+ $record = $this->getRecord();
+ if ($record && isset($record->keep_revisions)) {
+ return (bool) $record->keep_revisions;
+ }
+
+ return (bool) ($this->changes["keep_revisions"] ?? false);
+ }
+
+ public function getName(): string
+ {
+ if ($this->getRecord()) {
+ return (string) $this->getRecord()->name;
+ }
+
+ return (string) ($this->changes["name"] ?? "");
+ }
+
+ /** Alias used by wiki-style templates. */
+ public function getTitle(): string
+ {
+ return $this->getName();
+ }
+
+ public function getEditor(): ?User
+ {
+ $revision = DatabaseConnection::i()->getContext()->table("note_revisions")
+ ->where("note", $this->getId())
+ ->order("created DESC")
+ ->limit(1)
+ ->fetch();
+
+ if (!$revision) {
+ return $this->getCreatedBy();
+ }
+
+ return (new Users())->get((int) $revision->editor);
+ }
+
+ public function getPreview(int $length = 25): string
+ {
+ return ovk_proc_strtr(strip_tags($this->getSource()), $length);
+ }
+
+ public function getSource(): string
+ {
+ if ($this->getRecord()) {
+ return (string) $this->getRecord()->source;
+ }
+
+ return (string) ($this->changes["source"] ?? "");
+ }
+
+ public function getURL(): string
+ {
+ if ($this->isClubNote()) {
+ return "/note-" . abs($this->getOwnerId()) . "_" . $this->getVirtualId();
+ }
+
+ return "/note" . $this->getOwnerId() . "_" . $this->getVirtualId();
+ }
+
+ public function getListURL(): string
+ {
+ if ($this->isClubNote()) {
+ return "/notes-" . abs($this->getOwnerId());
+ }
+
+ return "/notes" . $this->getOwnerId();
+ }
+
protected function renderHTML(?string $content = null): string
{
$config = HTMLPurifier_Config::createDefault();
@@ -54,91 +236,158 @@ protected function renderHTML(?string $content = null): string
$config->set("HTML.Doctype", "XHTML 1.1");
$config->set("HTML.TidyLevel", "heavy");
$config->set("HTML.AllowedElements", [
- "div",
- "h3",
- "h4",
- "h5",
- "h6",
- "p",
- "i",
- "b",
- "a",
- "del",
- "ins",
- "sup",
- "sub",
- "table",
- "thead",
- "tbody",
- "tr",
- "td",
- "th",
- "img",
- "ul",
- "ol",
- "li",
- "hr",
- "br",
- "acronym",
- "blockquote",
- "cite",
- "span",
+ "div", "h3", "h4", "h5", "h6", "p", "i", "b", "a", "del", "ins", "sup", "sub",
+ "table", "thead", "tbody", "tr", "td", "th", "img", "ul", "ol", "li", "hr", "br",
+ "acronym", "blockquote", "cite", "span",
]);
$config->set("HTML.AllowedAttributes", [
- "table.summary",
- "td.abbr",
- "th.abbr",
- "a.href",
- "img.src",
- "img.alt",
- "img.style",
- "div.style",
- "div.title",
- "span.class",
- "p.class",
+ "table.summary", "td.abbr", "th.abbr", "a.href", "img.src", "img.alt", "img.style",
+ "div.style", "div.title", "div.align", "span.class", "p.class", "p.align",
]);
$config->set("CSS.AllowedProperties", [
- "float",
- "height",
- "width",
- "max-height",
- "max-width",
- "font-weight",
+ "float", "height", "width", "max-height", "max-width", "font-weight", "text-align",
]);
- $config->set("Attr.AllowedClasses", [
- "underline",
- ]);
- $config->set('Filter.Custom', [new SecurityFilter()]);
+ $config->set("Attr.AllowedClasses", ["underline"]);
+ $config->set("Filter.Custom", [new SecurityFilter()]);
$source = $content;
if (!$source) {
- if (is_null($this->getRecord())) {
- if (isset($this->changes["source"])) {
- $source = $this->changes["source"];
- } else {
- throw new \LogicException("Can't render note without content set.");
- }
- } else {
- $source = $this->getRecord()->source;
+ $source = $this->getSource();
+ if ($source === "" && is_null($this->getRecord()) && !isset($this->changes["source"])) {
+ throw new \LogicException("Can't render note without content set.");
}
}
- $purifier = new HTMLPurifier($config);
- return $purifier->purify($source);
+ return (new HTMLPurifier($config))->purify($source);
}
- public function getName(): string
+ public static function renderMarkdown(string $source, ?Club $club = null, ?User $userOwner = null, ?User $viewer = null): string
{
- return $this->getRecord()->name;
+ $notes = new Notes();
+ $ownerId = $club ? -$club->getId() : ($userOwner ? $userOwner->getId() : 0);
+ $createBase = $club
+ ? "/notes-" . $club->getId() . "/create"
+ : "/notes/create";
+
+ $processed = preg_replace_callback(
+ '/\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]/u',
+ function (array $matches) use ($notes, $ownerId, $club, $createBase): string {
+ $target = trim($matches[1]);
+ $label = isset($matches[2]) ? trim($matches[2]) : $target;
+ $note = $ownerId !== 0 ? $notes->getByTitle($ownerId, $target) : null;
+
+ if ($note) {
+ return "[" . str_replace(["[", "]"], ["\\[", "\\]"], $label) . "](" . $note->getURL() . ")";
+ }
+
+ $createUrl = $createBase . "?title=" . rawurlencode($target);
+ return "[" . str_replace(["[", "]"], ["\\[", "\\]"], $label) . "](" . $createUrl . ")";
+ },
+ $source
+ );
+
+ $html = (new Parsedown())->text($processed ?? $source);
+
+ $html = preg_replace_callback(
+ '/<(t[dh])(\s[^>]*)?\sstyle="text-align:\s*(left|center|right);?"([^>]*)>/i',
+ static function (array $m): string {
+ $rest = ($m[2] ?? "") . ($m[4] ?? "");
+ $rest = preg_replace('/\sstyle="[^"]*"/i', "", $rest) ?? $rest;
+ return "<{$m[1]} align=\"{$m[3]}\"{$rest}>";
+ },
+ $html
+ ) ?? $html;
+
+ $html = preg_replace_callback(
+ '//',
+ static function (array $m): string {
+ return '';
+ },
+ $html
+ );
+
+ $config = HTMLPurifier_Config::createDefault();
+ $config->set("Attr.AllowedClasses", ["wiki-missing", "underline", "wiki_md_table"]);
+ $config->set("Attr.DefaultInvalidImageAlt", "Unknown image");
+ $config->set("AutoFormat.AutoParagraph", false);
+ $config->set("AutoFormat.Linkify", true);
+ $config->set("URI.Base", "//$_SERVER[SERVER_NAME]/");
+ $config->set("URI.Munge", "/away.php?xinf=%n.%m:%r&css=%p&to=%s");
+ $config->set("URI.MakeAbsolute", true);
+ $config->set("HTML.Doctype", "XHTML 1.1");
+ $config->set("HTML.TidyLevel", "heavy");
+ $config->set("HTML.AllowedElements", [
+ "div", "h1", "h2", "h3", "h4", "h5", "h6", "p", "i", "b", "em", "strong",
+ "a", "del", "ins", "sup", "sub", "table", "thead", "tbody", "tr", "td", "th",
+ "img", "ul", "ol", "li", "hr", "br", "blockquote", "cite", "span", "code", "pre",
+ ]);
+ $config->set("HTML.AllowedAttributes", [
+ "table.summary", "table.class", "td.abbr", "th.abbr", "a.href", "a.class", "a.title",
+ "img.src", "img.alt", "img.style", "div.style", "div.title", "span.class", "p.class",
+ "td.align", "th.align", "td.style", "th.style", "p.align", "div.align",
+ ]);
+ $config->set("CSS.AllowedProperties", [
+ "float", "height", "width", "max-height", "max-width", "font-weight", "text-align",
+ ]);
+ $config->set("Filter.Custom", [new SecurityFilter()]);
+
+ $html = (new HTMLPurifier($config))->purify($html);
+ $html = preg_replace('/
]*\bclass=)/i', 'getRecord()->source), $length);
+ $replaced = preg_replace_callback(
+ '/
]*?)src="(?:(?:https?:)?\/\/[^\/"]+)?\/?photo(-?\d+)_(\d+)"([^>]*)>/i',
+ static function (array $m) use ($viewer): string {
+ $photo = (new Photos())->getByOwnerAndVID((int) $m[2], (int) $m[3]);
+ if (!$photo || $photo->isDeleted() || !$photo->canBeViewedBy($viewer)) {
+ return $m[0];
+ }
+
+ $src = htmlspecialchars($photo->getURLBySizeId("normal"), ENT_QUOTES);
+ $href = htmlspecialchars($photo->getPageURL(), ENT_QUOTES);
+
+ return '
';
+ },
+ $html
+ );
+
+ return $replaced ?? $html;
}
- public function getText(): string
+ public function getText(User|int|null $viewer = null): string
{
+ if (is_int($viewer)) {
+ $viewer = null;
+ }
+
+ if ($this->isMarkdown()) {
+ if (is_null($this->getRecord())) {
+ return self::renderMarkdown(
+ $this->getSource(),
+ $this->getClub(),
+ $this->isClubNote() ? null : ($this->getCreatedBy() ?? null),
+ $viewer
+ );
+ }
+
+ $cached = $this->getRecord()->cached_content;
+ if (!$cached) {
+ $cached = self::renderMarkdown(
+ $this->getSource(),
+ $this->getClub(),
+ $this->isClubNote() ? null : (new Users())->get(abs($this->getOwnerId()))
+ );
+ $this->changes["cached_content"] = $cached;
+ parent::save(false);
+ }
+
+ return self::resolvePhotoEmbeds($cached, $viewer);
+ }
+
if (is_null($this->getRecord())) {
return $this->renderHTML();
}
@@ -147,37 +396,216 @@ public function getText(): string
if (!$cached) {
$cached = $this->renderHTML();
$this->setCached_Content($cached);
- $this->save();
+ parent::save(false);
}
return $this->renderHTML($cached);
}
- public function getSource(): string
+ private function checkAccessLevel(int $level, ?User $user): bool
{
- return $this->getRecord()->source;
+ if ($level === self::ACCESS_EVERYONE) {
+ return true;
+ }
+
+ if (!$user) {
+ return false;
+ }
+
+ if ($this->isClubNote()) {
+ $club = $this->getClub();
+ if (!$club) {
+ return false;
+ }
+
+ if ($level === self::ACCESS_ADMINS) {
+ return $club->canBeModifiedBy($user);
+ }
+
+ return $club->getSubscriptionStatus($user)
+ || $club->canBeModifiedBy($user);
+ }
+
+ $owner = $this->getOwner();
+ if (!($owner instanceof User)) {
+ return false;
+ }
+
+ if ($level === self::ACCESS_ADMINS) {
+ return $this->canBeModifiedBy($user);
+ }
+
+ return $owner->getId() === $user->getId()
+ || $owner->getSubscriptionStatus($user) === User::SUBSCRIPTION_MUTUAL;
}
public function canBeViewedBy(?User $user = null): bool
{
- if ($this->isDeleted() || $this->getOwner()->isDeleted()) {
+ if ($this->isDeleted()) {
+ return false;
+ }
+
+ if ($this->isClubNote()) {
+ $club = $this->getClub();
+ if (!$club || $club->isBanned() || !$club->isPagesEnabled()) {
+ return false;
+ }
+
+ return $this->checkAccessLevel($this->getViewAccess(), $user);
+ }
+
+ $owner = $this->getOwner();
+ if (!($owner instanceof User) || $owner->isDeleted()) {
+ return false;
+ }
+
+ return $owner->getPrivacyPermission("notes.read", $user) && $owner->canBeViewedBy($user);
+ }
+
+ public function canBeEditedBy(?User $user = null): bool
+ {
+ if (!$user || $this->isDeleted()) {
+ return false;
+ }
+
+ if ($this->isClubNote()) {
+ $club = $this->getClub();
+ if (!$club || $club->isBanned() || !$club->isPagesEnabled()) {
+ return false;
+ }
+
+ return $this->checkAccessLevel($this->getEditAccess(), $user);
+ }
+
+ return $this->canBeModifiedBy($user);
+ }
+
+ public function canBeCommentedBy(?User $user = null): bool
+ {
+ if (!$user || !$this->canBeViewedBy($user)) {
return false;
}
- return $this->getOwner()->getPrivacyPermission('notes.read', $user) && $this->getOwner()->canBeViewedBy($user);
+ return $this->checkAccessLevel($this->getCommentAccess(), $user);
+ }
+
+ public function setRevisionEditor(int $userId): void
+ {
+ $this->revisionEditorId = $userId;
+ }
+
+ public function makeMain(): void
+ {
+ if (!$this->isClubNote()) {
+ return;
+ }
+
+ DatabaseConnection::i()->getContext()->table("notes")
+ ->where([
+ "owner" => $this->getOwnerId(),
+ "is_main" => 1,
+ "deleted" => 0,
+ ])
+ ->update(["is_main" => 0]);
+
+ $this->changes["is_main"] = 1;
+ parent::save(false);
+ }
+
+ public function delete(bool $softly = true): void
+ {
+ if (!$softly) {
+ parent::delete(false);
+ return;
+ }
+
+ $this->setDeleted(1);
+ if ($this->isClubNote()) {
+ $this->changes["is_main"] = 0;
+ }
+ parent::save(false);
+ }
+
+ public function save(?bool $log = false): void
+ {
+ $isNew = is_null($this->getRecord());
+ $record = $this->getRecord();
+
+ $editorId = $this->revisionEditorId;
+ unset($this->changes["_revision_editor"]);
+ $this->revisionEditorId = null;
+
+ $nameChanged = isset($this->changes["name"])
+ && (!$record || (string) $this->changes["name"] !== (string) $record->name);
+ $sourceChanged = isset($this->changes["source"])
+ && (!$record || (string) $this->changes["source"] !== (string) $record->source);
+
+ if (isset($this->changes["name"]) && !$nameChanged && !$isNew) {
+ unset($this->changes["name"]);
+ }
+ if (isset($this->changes["source"]) && !$sourceChanged && !$isNew) {
+ unset($this->changes["source"]);
+ }
+
+ $contentChanged = $isNew || $nameChanged || $sourceChanged;
+
+ if ($isNew) {
+ if (!isset($this->changes["format"])) {
+ $this->changes["format"] = self::FORMAT_MARKDOWN;
+ }
+ if (!isset($this->changes["comment_access"])) {
+ $this->changes["comment_access"] = self::ACCESS_EVERYONE;
+ }
+ if ($this->isClubNote()) {
+ if (!isset($this->changes["view_access"])) {
+ $this->changes["view_access"] = self::ACCESS_EVERYONE;
+ }
+ if (!isset($this->changes["edit_access"])) {
+ $this->changes["edit_access"] = self::ACCESS_ADMINS;
+ }
+ }
+ } elseif ($contentChanged) {
+ $this->changes["edited"] = time();
+ $this->changes["cached_content"] = null;
+ }
+
+ $revTitle = $contentChanged
+ ? (string) ($this->changes["name"] ?? ($record->name ?? ""))
+ : null;
+ $revSource = $contentChanged
+ ? (string) ($this->changes["source"] ?? ($record->source ?? ""))
+ : null;
+
+ $keepRevisions = $this->keepsRevisions();
+ if (isset($this->changes["keep_revisions"])) {
+ $keepRevisions = (bool) $this->changes["keep_revisions"];
+ }
+
+ parent::save($log);
+
+ if ($contentChanged && $editorId !== null && $keepRevisions) {
+ DatabaseConnection::i()->getContext()->table("note_revisions")->insert([
+ "note" => $this->getId(),
+ "editor" => (int) $editorId,
+ "title" => $revTitle,
+ "source" => $revSource,
+ "created" => time(),
+ ]);
+ (new Notes())->pruneRevisions($this, 50);
+ }
}
public function toVkApiStruct(): object
{
$res = (object) [];
- $res->id = $this->getVirtualId();
- $res->owner_id = $this->getOwner()->getId();
- $res->title = $this->getName();
- $res->text = $this->getText();
- $res->date = $this->getPublicationTime()->timestamp();
- $res->comments = $this->getCommentsCount();
- $res->view_url = "/note" . $this->getOwner()->getId() . "_" . $this->getVirtualId();
+ $res->id = $this->getVirtualId();
+ $res->owner_id = $this->getOwnerId();
+ $res->title = $this->getName();
+ $res->text = $this->getText();
+ $res->date = $this->getPublicationTime()->timestamp();
+ $res->comments = $this->getCommentsCount();
+ $res->view_url = $this->getURL();
return $res;
}
diff --git a/Web/Models/Entities/NoteRevision.php b/Web/Models/Entities/NoteRevision.php
new file mode 100644
index 000000000..c6bde5feb
--- /dev/null
+++ b/Web/Models/Entities/NoteRevision.php
@@ -0,0 +1,44 @@
+getRecord()->id;
+ }
+
+ public function getNoteId(): int
+ {
+ return (int) $this->getRecord()->note;
+ }
+
+ public function getEditor(): ?User
+ {
+ return (new Users())->get((int) $this->getRecord()->editor);
+ }
+
+ public function getTitle(): string
+ {
+ return (string) $this->getRecord()->title;
+ }
+
+ public function getSource(): string
+ {
+ return (string) $this->getRecord()->source;
+ }
+
+ public function getCreationTime(): DateTime
+ {
+ return new DateTime((int) $this->getRecord()->created);
+ }
+}
diff --git a/Web/Models/Repositories/Notes.php b/Web/Models/Repositories/Notes.php
index 73b2137ac..b730ce175 100644
--- a/Web/Models/Repositories/Notes.php
+++ b/Web/Models/Repositories/Notes.php
@@ -5,9 +5,9 @@
namespace openvk\Web\Models\Repositories;
use Chandler\Database\DatabaseConnection;
-use openvk\Web\Models\Entities\Note;
-use openvk\Web\Models\Entities\User;
+use openvk\Web\Models\Entities\{Note, NoteRevision, User, Club};
use Nette\Database\Table\ActiveRow;
+use Nette\Database\Table\Selection;
class Notes
{
@@ -22,36 +22,148 @@ public function __construct()
$this->notes = $this->context->table("notes");
}
+ private function table(): Selection
+ {
+ return $this->context->table("notes");
+ }
+
+ private function revisions(): Selection
+ {
+ return $this->context->table("note_revisions");
+ }
+
private function toNote(?ActiveRow $ar): ?Note
{
return is_null($ar) ? null : new Note($ar);
}
+ private function toRevision(?ActiveRow $ar): ?NoteRevision
+ {
+ return is_null($ar) ? null : new NoteRevision($ar);
+ }
+
public function get(int $id): ?Note
{
- return self::$cache[$id] ??= $this->toNote($this->notes->get($id));
+ return self::$cache[$id] ??= $this->toNote($this->table()->get($id));
+ }
+
+ public function getNoteById(int $owner, int $note): ?Note
+ {
+ return $this->toNote($this->table()->where([
+ "owner" => $owner,
+ "virtual_id" => $note,
+ "deleted" => 0,
+ ])->fetch());
+ }
+
+ public function getByTitle(int $ownerId, string $title): ?Note
+ {
+ return $this->toNote($this->table()->where([
+ "owner" => $ownerId,
+ "name" => $title,
+ "deleted" => 0,
+ ])->fetch());
}
public function getUserNotes(User $user, int $page = 1, ?int $perPage = null, string $sort = "DESC"): \Traversable
{
$perPage ??= OPENVK_DEFAULT_PER_PAGE;
- foreach ($this->notes->where("owner", $user->getId())->where("deleted", 0)->order("created $sort")->page($page, $perPage) as $album) {
- yield new Note($album);
+ foreach ($this->table()->where("owner", $user->getId())->where("deleted", 0)->order("created $sort")->page($page, $perPage) as $row) {
+ yield new Note($row);
}
}
- public function getNoteById(int $owner, int $note): ?Note
+ public function getUserNotesCount(User $user): int
{
- $note = $this->notes->where(['owner' => $owner, 'virtual_id' => $note])->fetch();
- if (!is_null($note)) {
- return new Note($note);
- } else {
- return null;
+ return sizeof($this->table()->where("owner", $user->getId())->where("deleted", 0));
+ }
+
+ public function getClubNotes(Club $club, int $page = 1, ?int $perPage = null): \Traversable
+ {
+ $perPage ??= OPENVK_DEFAULT_PER_PAGE;
+ $rows = $this->table()->where([
+ "owner" => -$club->getId(),
+ "deleted" => 0,
+ ])->order("is_main DESC, edited DESC, created DESC")->page($page, $perPage);
+
+ foreach ($rows as $row) {
+ yield $this->toNote($row);
}
}
- public function getUserNotesCount(User $user): int
+ public function getClubNotesCount(Club $club): int
+ {
+ return sizeof($this->table()->where([
+ "owner" => -$club->getId(),
+ "deleted" => 0,
+ ]));
+ }
+
+ public function getMainNote(Club $club): ?Note
+ {
+ return $this->toNote($this->table()->where([
+ "owner" => -$club->getId(),
+ "is_main" => 1,
+ "deleted" => 0,
+ ])->order("created ASC")->fetch());
+ }
+
+ public function ensureSingleMain(Club $club): void
+ {
+ $mains = $this->table()->where([
+ "owner" => -$club->getId(),
+ "deleted" => 0,
+ "is_main" => 1,
+ ])->order("created ASC");
+
+ $keepId = null;
+ foreach ($mains as $row) {
+ if ($keepId === null) {
+ $keepId = (int) $row->id;
+ continue;
+ }
+
+ $row->update(["is_main" => 0]);
+ unset(self::$cache[(int) $row->id]);
+ }
+ }
+
+ public function getRevisions(Note $note, int $pageNum = 1, ?int $perPage = null): \Traversable
+ {
+ $perPage ??= OPENVK_DEFAULT_PER_PAGE;
+ $rows = $this->revisions()->where("note", $note->getId())
+ ->order("created DESC")
+ ->page($pageNum, $perPage);
+
+ foreach ($rows as $row) {
+ yield $this->toRevision($row);
+ }
+ }
+
+ public function getRevisionsCount(Note $note): int
{
- return sizeof($this->notes->where("owner", $user->getId())->where("deleted", 0));
+ return sizeof($this->revisions()->where("note", $note->getId()));
+ }
+
+ public function getRevision(Note $note, int $revisionId): ?NoteRevision
+ {
+ return $this->toRevision($this->revisions()->where([
+ "id" => $revisionId,
+ "note" => $note->getId(),
+ ])->fetch());
+ }
+
+ public function pruneRevisions(Note $note, int $keep = 50): void
+ {
+ $keepIds = [];
+ foreach ($this->revisions()->where("note", $note->getId())->order("created DESC")->limit($keep) as $row) {
+ $keepIds[] = (int) $row->id;
+ }
+
+ if (sizeof($keepIds) === 0) {
+ return;
+ }
+
+ $this->revisions()->where("note", $note->getId())->where("id NOT", $keepIds)->delete();
}
}
diff --git a/Web/Presenters/CommentPresenter.php b/Web/Presenters/CommentPresenter.php
index 33e1e149c..0a1e06e58 100644
--- a/Web/Presenters/CommentPresenter.php
+++ b/Web/Presenters/CommentPresenter.php
@@ -4,7 +4,7 @@
namespace openvk\Web\Presenters;
-use openvk\Web\Models\Entities\{Comment, Notifications\MentionNotification, Notifications\ReplyCommentNotification, Photo, Video, User, Topic, Post};
+use openvk\Web\Models\Entities\{Comment, Notifications\MentionNotification, Notifications\ReplyCommentNotification, Photo, Video, User, Topic, Post, Note};
use openvk\Web\Models\Entities\Notifications\CommentNotification;
use openvk\Web\Models\Repositories\{Comments, Clubs, Videos, Photos, Audios};
use Nette\InvalidStateException as ISE;
@@ -66,6 +66,10 @@ public function renderMakeComment(string $repo, int $eId): void
$this->flashFail("err", tr("error"), tr("forbidden"));
}
+ if ($entity instanceof Note && !$entity->canBeCommentedBy($this->user->identity)) {
+ $this->flashFail("err", tr("error"), tr("forbidden"));
+ }
+
if ($entity instanceof Topic && $entity->isClosed()) {
$this->notFound();
}
diff --git a/Web/Presenters/GroupPresenter.php b/Web/Presenters/GroupPresenter.php
index 9f6b6b7eb..f01b9fa3b 100644
--- a/Web/Presenters/GroupPresenter.php
+++ b/Web/Presenters/GroupPresenter.php
@@ -7,7 +7,7 @@
use openvk\Web\Models\Entities\{Club, Photo, Post};
use Nette\InvalidStateException;
use openvk\Web\Models\Entities\Notifications\ClubModeratorNotification;
-use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics, Audios, Posts, Documents};
+use openvk\Web\Models\Repositories\{Clubs, Users, Albums, Managers, Topics, Audios, Posts, Documents, Notes};
use Chandler\Security\Authenticator;
use Nette\InvalidStateException as ISE;
use Chandler\Session\Session;
@@ -42,6 +42,9 @@ public function renderView(int $id): void
$this->template->audiosCount = (new Audios())->getClubCollectionSize($club);
$this->template->docsCount = $docs->size();
$this->template->docs = $docs->offsetLimit(0, 2);
+ $notes = new Notes();
+ $this->template->pagesCount = $notes->getClubNotesCount($club);
+ $this->template->mainPage = $notes->getMainNote($club);
}
if (!is_null($this->user->identity) && $club->getWallType() == 2) {
@@ -334,6 +337,11 @@ public function renderEdit(int $id): void
$club->setEveryone_Can_Create_Topics(empty($this->postParam("everyone_can_create_topics")) ? 0 : 1);
$club->setDisplay_Topics_Above_Wall(empty($this->postParam("display_topics_above_wall")) ? 0 : 1);
$club->setEveryone_can_upload_audios(empty($this->postParam("upload_audios")) ? 0 : 1);
+ try {
+ $club->setPages(empty($this->postParam("pages")) ? 0 : (int) $this->postParam("pages"));
+ } catch (\Exception $e) {
+ $this->flashFail("err", tr("error"), tr("error_invalid_pages_value"));
+ }
if (!$club->isHidingFromGlobalFeedEnforced()) {
$club->setHide_From_Global_Feed(empty($this->postParam("hide_from_global_feed") ? 0 : 1));
diff --git a/Web/Presenters/NotesPresenter.php b/Web/Presenters/NotesPresenter.php
index 2e0341191..8b6c7492f 100644
--- a/Web/Presenters/NotesPresenter.php
+++ b/Web/Presenters/NotesPresenter.php
@@ -4,28 +4,121 @@
namespace openvk\Web\Presenters;
-use openvk\Web\Models\Repositories\{Users, Notes};
-use openvk\Web\Models\Entities\Note;
+use Chandler\Database\DatabaseConnection;
+use openvk\Web\Models\Entities\{Club, Note, User};
+use openvk\Web\Models\Repositories\{Users, Notes, Clubs};
final class NotesPresenter extends OpenVKPresenter
{
- private $notes;
+ private Notes $notes;
+ private Clubs $clubs;
protected $presenterName = "notes";
- public function __construct(Notes $notes)
+ public function __construct(Notes $notes, Clubs $clubs)
{
$this->notes = $notes;
+ $this->clubs = $clubs;
parent::__construct();
}
+ private function isClubNotesRequest(): bool
+ {
+ $path = parse_url($_SERVER["REQUEST_URI"] ?? "", PHP_URL_PATH) ?: "";
+
+ return (bool) preg_match('#^/note(s)?-#', $path);
+ }
+
+ private function getClubOrFail(int $id)
+ {
+ $club = $this->clubs->get($id);
+ if (!$club || $club->isBanned()) {
+ $this->notFound();
+ }
+
+ return $club;
+ }
+
+ private function assertPagesEnabled($club): void
+ {
+ if (!$club->isPagesEnabled()) {
+ $this->notFound();
+ }
+ }
+
+ private function getClubNoteOrFail(int $clubId, int $virtualId): Note
+ {
+ $page = $this->notes->getNoteById(-$clubId, $virtualId);
+ if (!$page) {
+ $this->notFound();
+ }
+
+ return $page;
+ }
+
+ private function postedName(): string
+ {
+ $title = trim((string) ($this->postParam("title") ?? ""));
+ if ($title !== "") {
+ return $title;
+ }
+
+ return trim((string) ($this->postParam("name") ?? ""));
+ }
+
+ private function postedSource(bool $allowHtmlFormat = true): string
+ {
+ if ($allowHtmlFormat && $this->postedFormat() === Note::FORMAT_HTML) {
+ $html = $this->postParam("html");
+ if ($html !== null) {
+ return (string) $html;
+ }
+ }
+
+ $source = $this->postParam("source");
+ if ($source !== null) {
+ return (string) $source;
+ }
+
+ if (!$allowHtmlFormat) {
+ return "";
+ }
+
+ return (string) ($this->postParam("html") ?? "");
+ }
+
+ private function postedFormat(): int
+ {
+ $format = (int) ($this->postParam("format") ?? Note::FORMAT_MARKDOWN);
+ if ($format !== Note::FORMAT_HTML && $format !== Note::FORMAT_MARKDOWN) {
+ return Note::FORMAT_MARKDOWN;
+ }
+
+ return $format;
+ }
+
+ private function postedAccess(string $field, int $default): int
+ {
+ $value = (int) ($this->postParam($field) ?? $default);
+ if ($value < 0 || $value > 2) {
+ $this->flashFail("err", tr("error"), tr("error_segmentation"));
+ }
+
+ return $value;
+ }
+
public function renderList(int $owner): void
{
+ if ($this->isClubNotesRequest()) {
+ $this->renderClubList($owner);
+ return;
+ }
+
$user = (new Users())->get($owner);
if (!$user) {
$this->notFound();
}
- if (!$user->getPrivacyPermission('notes.read', $this->user->identity ?? null)) {
+ if (!$user->getPrivacyPermission("notes.read", $this->user->identity ?? null)) {
$this->flashFail("err", tr("forbidden"), tr("forbidden_comment"));
}
@@ -37,67 +130,59 @@ public function renderList(int $owner): void
public function renderView(int $owner, int $note_id): void
{
+ if ($this->isClubNotesRequest()) {
+ $this->renderClubView($owner, $note_id);
+ return;
+ }
+
$note = $this->notes->getNoteById($owner, $note_id);
- if (!$note || $note->getOwner()->getId() !== $owner || $note->isDeleted()) {
+ if (!$note || $note->isDeleted()) {
+ $this->notFound();
+ }
+ $noteOwner = $note->getOwner();
+ if (!($noteOwner instanceof User) || $noteOwner->getId() !== $owner) {
$this->notFound();
}
- if (!$note->getOwner()->getPrivacyPermission('notes.read', $this->user->identity ?? null)) {
+ if (!$noteOwner->getPrivacyPermission("notes.read", $this->user->identity ?? null)) {
$this->flashFail("err", tr("forbidden"), tr("forbidden_comment"));
}
if (!$note->canBeViewedBy($this->user->identity)) {
$this->flashFail("err", tr("forbidden"), tr("forbidden_comment"));
}
- $this->template->cCount = $note->getCommentsCount();
- $this->template->cPage = (int) ($this->queryParam("p") ?? 1);
- $this->template->comments = iterator_to_array($note->getComments($this->template->cPage));
- $this->template->note = $note;
+ $this->assignComments($note);
+ $this->template->note = $note;
}
- public function renderPreView(): void
+ public function renderCreate(?int $owner = null): void
{
$this->assertUserLoggedIn();
- $this->willExecuteWriteAction();
- if ($_SERVER["REQUEST_METHOD"] !== "POST") {
- header("HTTP/1.1 400 Bad Request");
- exit;
- }
-
- if (empty($this->postParam("html")) || empty($this->postParam("title"))) {
- header("HTTP/1.1 400 Bad Request");
- exit(tr("note_preview_empty_err"));
+ if ($owner !== null && $this->isClubNotesRequest()) {
+ $this->renderClubCreate($owner);
+ return;
}
- $note = new Note();
- $note->setSource($this->postParam("html"));
-
- $this->flash("info", tr("note_preview_warn"), tr("note_preview_warn_details"));
- $this->template->title = $this->postParam("title");
- $this->template->html = $note->getText();
- }
-
- public function renderCreate(): void
- {
- $this->assertUserLoggedIn();
- $this->willExecuteWriteAction();
-
- $id = $this->user->id; #TODO: when ACL'll be done, allow admins to edit users via ?GUID=(chandler guid)
-
+ $id = $this->user->id;
if (!$id) {
$this->notFound();
}
if ($_SERVER["REQUEST_METHOD"] === "POST") {
- if (empty($this->postParam("name"))) {
- $this->flashFail("err", tr("error"), tr("error_segmentation"));
+ $this->willExecuteWriteAction();
+ $this->assertNoCSRF();
+ $name = $this->postedName();
+ if ($name === "") {
+ $this->flashFail("err", tr("error"), tr("page_no_title"));
}
$note = new Note();
$note->setOwner($this->user->id);
$note->setCreated(time());
- $note->setName($this->postParam("name"));
- $note->setSource($this->postParam("html"));
+ $note->setName(ovk_proc_strtr($name, 255));
+ $note->setSource($this->postedSource());
+ $note->setFormat($this->postedFormat());
+ $note->setComment_Access($this->postedAccess("comment_access", Note::ACCESS_EVERYONE));
$note->setEdited(time());
$note->save();
@@ -108,11 +193,14 @@ public function renderCreate(): void
public function renderEdit(int $owner, int $note_id): void
{
$this->assertUserLoggedIn();
- $this->willExecuteWriteAction();
- $note = $this->notes->getNoteById($owner, $note_id);
+ if ($this->isClubNotesRequest()) {
+ $this->renderClubEdit($owner, $note_id);
+ return;
+ }
- if (!$note || $note->getOwner()->getId() !== $owner || $note->isDeleted()) {
+ $note = $this->notes->getNoteById($owner, $note_id);
+ if (!$note || $note->isDeleted()) {
$this->notFound();
}
if (is_null($this->user->identity) || !$note->canBeModifiedBy($this->user->identity)) {
@@ -121,12 +209,16 @@ public function renderEdit(int $owner, int $note_id): void
$this->template->note = $note;
if ($_SERVER["REQUEST_METHOD"] === "POST") {
- if (empty($this->postParam("name"))) {
- $this->flashFail("err", tr("error"), tr("error_segmentation"));
+ $this->willExecuteWriteAction();
+ $this->assertNoCSRF();
+ $name = $this->postedName();
+ if ($name === "") {
+ $this->flashFail("err", tr("error"), tr("page_no_title"));
}
- $note->setName($this->postParam("name"));
- $note->setSource($this->postParam("html"));
+ $note->setName(ovk_proc_strtr($name, 255));
+ $note->setSource($this->postedSource());
+ $note->setComment_Access($this->postedAccess("comment_access", $note->getCommentAccess()));
$note->setCached_Content(null);
$note->setEdited(time());
$note->save();
@@ -141,6 +233,11 @@ public function renderDelete(int $owner, int $id): void
$this->willExecuteWriteAction();
$this->assertNoCSRF();
+ if ($this->isClubNotesRequest()) {
+ $this->renderClubDelete($owner, $id);
+ return;
+ }
+
$note = $this->notes->get($id);
if (!$note) {
$this->notFound();
@@ -157,4 +254,416 @@ public function renderDelete(int $owner, int $id): void
$this->flash("succ", tr("note_is_deleted"), tr("note_x_is_now_deleted", $name));
$this->redirect("/notes" . $this->user->id);
}
+
+ public function renderPreview(): void
+ {
+ $this->assertUserLoggedIn();
+ $this->assertNoCSRF();
+
+ if ($_SERVER["REQUEST_METHOD"] !== "POST") {
+ header("HTTP/1.1 400 Bad Request");
+ exit;
+ }
+
+ $source = $this->postedSource();
+ $format = $this->postedFormat();
+ $clubId = (int) ($this->postParam("club") ?? 0);
+ $viewer = $this->user->identity instanceof User ? $this->user->identity : null;
+
+ header("Content-Type: text/html; charset=utf-8");
+
+ if ($clubId > 0) {
+ $club = $this->clubs->get($clubId);
+ if (!$club || $club->isBanned() || !$club->isPagesEnabled()) {
+ header("HTTP/1.1 404 Not Found");
+ exit;
+ }
+ if (!$this->canPreviewClubWiki($club)) {
+ header("HTTP/1.1 403 Forbidden");
+ exit;
+ }
+
+ exit(Note::renderMarkdown($source, $club, null, $viewer));
+ }
+
+ if ($format === Note::FORMAT_HTML) {
+ $note = new Note();
+ $note->setFormat(Note::FORMAT_HTML);
+ $note->setSource($source);
+ exit($note->getText($viewer));
+ }
+
+ exit(Note::renderMarkdown($source, null, $viewer, $viewer));
+ }
+
+ public function renderSetMain(int $clubId, int $virtualId): void
+ {
+ if (!$this->isClubNotesRequest()) {
+ $this->notFound();
+ }
+
+ $this->assertUserLoggedIn();
+ $this->willExecuteWriteAction();
+ $this->assertNoCSRF();
+
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $page = $this->getClubNoteOrFail($clubId, $virtualId);
+
+ if (!$club->canManagePages($this->user->identity)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ $page->makeMain();
+ $this->flash("succ", tr("success_action"), tr("page_set_main_succ"));
+ $this->redirect("/notes-" . $club->getId());
+ }
+
+ public function renderAccess(int $clubId, int $virtualId): void
+ {
+ if (!$this->isClubNotesRequest()) {
+ $this->notFound();
+ }
+
+ $this->assertUserLoggedIn();
+ $this->willExecuteWriteAction();
+ $this->assertNoCSRF();
+
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $page = $this->getClubNoteOrFail($clubId, $virtualId);
+
+ if (!$club->canManagePages($this->user->identity) && !$page->canBeEditedBy($this->user->identity)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ if ($_SERVER["REQUEST_METHOD"] !== "POST") {
+ $this->redirect($page->getURL() . "/edit");
+ }
+
+ $viewAccess = $this->postedAccess("view_access", 0);
+ $editAccess = $this->postedAccess("edit_access", 2);
+ $commentAccess = $this->postedAccess("comment_access", 0);
+
+ DatabaseConnection::i()->getContext()->table("notes")
+ ->where("id", $page->getId())
+ ->update([
+ "view_access" => $viewAccess,
+ "edit_access" => $editAccess,
+ "comment_access" => $commentAccess,
+ ]);
+
+ $this->flash("succ", tr("success_action"), tr("page_access_saved"));
+ $this->redirect($page->getURL() . "/edit");
+ }
+
+ public function renderHistory(int $clubId, int $virtualId): void
+ {
+ if (!$this->isClubNotesRequest()) {
+ $this->notFound();
+ }
+
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $page = $this->getClubNoteOrFail($clubId, $virtualId);
+
+ if (!$page->canBeViewedBy($this->user->identity ?? null)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ if (!$page->keepsRevisions()) {
+ $this->notFound();
+ }
+
+ $pageNum = (int) ($this->queryParam("p") ?? 1);
+ $this->template->_template = "Notes/ClubHistory.latte";
+ $this->template->club = $club;
+ $this->template->page = $page;
+ $this->template->tab = "history";
+ $this->template->revisions = $this->notes->getRevisions($page, $pageNum);
+ $this->template->count = $this->notes->getRevisionsCount($page);
+ $this->template->paginatorConf = (object) [
+ "count" => $this->template->count,
+ "page" => $pageNum,
+ "amount" => null,
+ "perPage" => OPENVK_DEFAULT_PER_PAGE,
+ "tidy" => false,
+ "atTop" => false,
+ ];
+ }
+
+ public function renderRevision(int $clubId, int $virtualId, int $revisionId): void
+ {
+ if (!$this->isClubNotesRequest()) {
+ $this->notFound();
+ }
+
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $page = $this->getClubNoteOrFail($clubId, $virtualId);
+
+ if (!$page->canBeViewedBy($this->user->identity ?? null)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ if (!$page->keepsRevisions()) {
+ $this->notFound();
+ }
+
+ $revision = $this->notes->getRevision($page, $revisionId);
+ if (!$revision) {
+ $this->notFound();
+ }
+
+ $this->template->_template = "Notes/ClubRevision.latte";
+ $this->template->club = $club;
+ $this->template->page = $page;
+ $this->template->revision = $revision;
+ $this->template->tab = "history";
+ $viewer = $this->user->identity instanceof User ? $this->user->identity : null;
+ $this->template->html = Note::renderMarkdown($revision->getSource(), $club, null, $viewer);
+
+ if ($_SERVER["REQUEST_METHOD"] === "POST" && $this->postParam("restore") === "1") {
+ $this->assertUserLoggedIn();
+ $this->willExecuteWriteAction();
+ $this->assertNoCSRF();
+
+ if (!$page->canBeEditedBy($this->user->identity)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ $page->setName($revision->getTitle());
+ $page->setSource($revision->getSource());
+ $page->setRevisionEditor($this->user->id);
+ $page->save();
+
+ $this->flash("succ", tr("success_action"), tr("page_restored"));
+ $this->redirect($page->getURL());
+ }
+ }
+
+ public function renderHelp(int $clubId): void
+ {
+ if (!$this->isClubNotesRequest()) {
+ $this->notFound();
+ }
+
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $this->template->_template = "Notes/ClubHelp.latte";
+ $this->template->club = $club;
+ }
+
+ private function canPreviewClubWiki(Club $club): bool
+ {
+ $user = $this->user->identity;
+ if (!$user instanceof User) {
+ return false;
+ }
+
+ return $club->canCreatePages($user)
+ || $club->canManagePages($user)
+ || $club->getSubscriptionStatus($user);
+ }
+
+ private function assignComments(Note $note): void
+ {
+ $this->template->cCount = $note->getCommentsCount();
+ $this->template->cPage = (int) ($this->queryParam("p") ?? 1);
+ $this->template->comments = iterator_to_array($note->getComments($this->template->cPage));
+ }
+
+ private function renderClubList(int $clubId): void
+ {
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $this->notes->ensureSingleMain($club);
+
+ $perPage = OPENVK_DEFAULT_PER_PAGE;
+ $page = max(1, (int) ($this->queryParam("p") ?? 1));
+ $count = $this->notes->getClubNotesCount($club);
+ $pageCount = max(1, (int) ceil($count / $perPage));
+ if ($page > $pageCount) {
+ $page = $pageCount;
+ }
+
+ $this->template->_template = "Notes/ClubList.latte";
+ $this->template->club = $club;
+ $this->template->pages = $this->notes->getClubNotes($club, $page, $perPage);
+ $this->template->count = $count;
+ $this->template->page = $page;
+ $this->template->showingFrom = $count === 0 ? 0 : (($page - 1) * $perPage + 1);
+ $this->template->showingTo = min($page * $perPage, $count);
+ $this->template->paginatorConf = (object) [
+ "count" => $count,
+ "page" => $page,
+ "amount" => null,
+ "perPage" => $perPage,
+ "tidy" => true,
+ "atTop" => true,
+ "space" => 6,
+ "pageCount" => $pageCount,
+ ];
+ $this->template->bottomPaginatorConf = clone $this->template->paginatorConf;
+ $this->template->bottomPaginatorConf->atTop = false;
+ $this->template->bottomPaginatorConf->atBottom = true;
+ $this->template->bottomPaginatorConf->tidy = false;
+ $this->template->bottomPaginatorConf->space = 11;
+ }
+
+ private function renderClubCreate(int $clubId): void
+ {
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+
+ if (!$club->canCreatePages($this->user->identity)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ $this->template->_template = "Notes/ClubCreate.latte";
+ $this->template->club = $club;
+ $this->template->prefillTitle = $this->queryParam("title") ?? "";
+
+ if ($_SERVER["REQUEST_METHOD"] !== "POST") {
+ return;
+ }
+
+ $this->willExecuteWriteAction();
+ $this->assertNoCSRF();
+ $title = $this->postedName();
+ $source = $this->postedSource(false);
+
+ if ($title === "") {
+ $this->flashFail("err", tr("error"), tr("page_no_title"));
+ }
+
+ if (mb_strlen($title) > 255) {
+ $this->flashFail("err", tr("error"), tr("page_title_too_long"));
+ }
+
+ $existing = $this->notes->getByTitle(-$club->getId(), $title);
+ if ($existing) {
+ $this->flashFail("err", tr("error"), tr("page_title_exists"));
+ }
+
+ $isFirst = $this->notes->getClubNotesCount($club) === 0;
+ $viewAccess = $this->postedAccess("view_access", Note::ACCESS_EVERYONE);
+ $editAccess = $this->postedAccess("edit_access", Note::ACCESS_ADMINS);
+ $commentAccess = $this->postedAccess("comment_access", Note::ACCESS_EVERYONE);
+ $keepRevisions = $this->postParam("keep_revisions") === "1" ? 1 : 0;
+
+ $page = new Note();
+ $page->setOwner(-$club->getId());
+ $page->setCreated_By($this->user->id);
+ $page->setName(ovk_proc_strtr($title, 255));
+ $page->setSource($source);
+ $page->setFormat(Note::FORMAT_MARKDOWN);
+ $page->setIs_Main($isFirst ? 1 : 0);
+ $page->setView_Access($viewAccess);
+ $page->setEdit_Access($editAccess);
+ $page->setComment_Access($commentAccess);
+ $page->setKeep_Revisions($keepRevisions);
+ $page->setRevisionEditor($this->user->id);
+ $page->save();
+
+ if ($isFirst) {
+ $page->makeMain();
+ }
+
+ $this->redirect($page->getURL());
+ }
+
+ private function renderClubView(int $clubId, int $virtualId): void
+ {
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $page = $this->getClubNoteOrFail($clubId, $virtualId);
+
+ if (!$page->canBeViewedBy($this->user->identity ?? null)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ $this->assignComments($page);
+ $this->template->_template = "Notes/ClubView.latte";
+ $this->template->club = $club;
+ $this->template->page = $page;
+ $this->template->tab = "view";
+ }
+
+ private function renderClubEdit(int $clubId, int $virtualId): void
+ {
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $page = $this->getClubNoteOrFail($clubId, $virtualId);
+
+ if (!$page->canBeEditedBy($this->user->identity)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ $this->template->_template = "Notes/ClubEdit.latte";
+ $this->template->club = $club;
+ $this->template->page = $page;
+ $this->template->tab = "edit";
+
+ if ($_SERVER["REQUEST_METHOD"] !== "POST") {
+ return;
+ }
+
+ $this->willExecuteWriteAction();
+ $this->assertNoCSRF();
+ $title = $this->postedName();
+ $source = $this->postedSource(false);
+
+ if ($title === "") {
+ $this->flashFail("err", tr("error"), tr("page_no_title"));
+ }
+
+ if (mb_strlen($title) > 255) {
+ $this->flashFail("err", tr("error"), tr("page_title_too_long"));
+ }
+
+ $existing = $this->notes->getByTitle(-$club->getId(), $title);
+ if ($existing && $existing->getId() !== $page->getId()) {
+ $this->flashFail("err", tr("error"), tr("page_title_exists"));
+ }
+
+ if ($this->postParam("keep_revisions") !== null) {
+ $page->setKeep_Revisions($this->postParam("keep_revisions") === "1" ? 1 : 0);
+ }
+
+ $page->setName(ovk_proc_strtr($title, 255));
+ $page->setSource($source);
+ $page->setRevisionEditor($this->user->id);
+ $page->save();
+
+ $this->redirect($page->getURL());
+ }
+
+ private function renderClubDelete(int $clubId, int $virtualId): void
+ {
+ $club = $this->getClubOrFail($clubId);
+ $this->assertPagesEnabled($club);
+ $page = $this->getClubNoteOrFail($clubId, $virtualId);
+
+ if (!$page->canBeEditedBy($this->user->identity) && !$club->canManagePages($this->user->identity)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ if (!$club->canBeModifiedBy($this->user->identity)) {
+ $this->flashFail("err", tr("error_access_denied_short"), tr("error_access_denied"));
+ }
+
+ $wasMain = $page->isMain();
+ $page->delete();
+
+ if ($wasMain) {
+ $pages = iterator_to_array($this->notes->getClubNotes($club, 1, 1));
+ if (isset($pages[0])) {
+ $pages[0]->makeMain();
+ }
+ }
+
+ $this->flash("succ", tr("page_deleted"), tr("page_deleted_descr"));
+ $this->redirect("/notes-" . $club->getId());
+ }
}
diff --git a/Web/Presenters/templates/Group/Edit.latte b/Web/Presenters/templates/Group/Edit.latte
index 111174a5d..0f112b19b 100644
--- a/Web/Presenters/templates/Group/Edit.latte
+++ b/Web/Presenters/templates/Group/Edit.latte
@@ -124,6 +124,22 @@
{_display_list_of_topics_above_wall}
+
+ |
+ {_materials}:
+ |
+
+
+ {_materials_hint}
+
+ |
+
|
{_group_administrators_list}:
diff --git a/Web/Presenters/templates/Group/View.latte b/Web/Presenters/templates/Group/View.latte
index 075c86d0f..36e2085b9 100644
--- a/Web/Presenters/templates/Group/View.latte
+++ b/Web/Presenters/templates/Group/View.latte
@@ -81,6 +81,39 @@
|
+
+
+
+ {_group_pages_main}
+
+
+
+ {tr("pages_count", $pagesCount)}
+
+
+
+
+
+ {_page_access_denied}
+
+
+
+
+
+
+
{var $followersCount = $club->getFollowersCount()}
@@ -224,6 +257,7 @@
{if $club->canBeModifiedBy($thisUser)}
{_edit_group}
+
{_edit_group_pages}
{_statistics}
{/if}
{if $thisUser->getChandlerUser()->can("access")->model("admin")->whichBelongsTo(NULL)}
diff --git a/Web/Presenters/templates/Notes/ClubCreate.latte b/Web/Presenters/templates/Notes/ClubCreate.latte
new file mode 100644
index 000000000..ac7f51da7
--- /dev/null
+++ b/Web/Presenters/templates/Notes/ClubCreate.latte
@@ -0,0 +1,78 @@
+{extends "../@layout.latte"}
+
+{block title}{_create_new_page} — {$club->getName()}{/block}
+
+{block header}
+
{$club->getName()}
+ »
+
{_group_pages}
+ »
+ {_create_new_page}
+{/block}
+
+{block content}
+{include "clubListTabs.latte", club => $club, mode => 'create'}
+
+
+
+{script "js/al_pages.js"}
+
+{/block}
diff --git a/Web/Presenters/templates/Notes/ClubEdit.latte b/Web/Presenters/templates/Notes/ClubEdit.latte
new file mode 100644
index 000000000..a033ef7d6
--- /dev/null
+++ b/Web/Presenters/templates/Notes/ClubEdit.latte
@@ -0,0 +1,78 @@
+{extends "../@layout.latte"}
+
+{block title}{_page_tab_edit} — {$page->getTitle()}{/block}
+
+{block header}
+
{$club->getName()}
+ »
+
{$page->getTitle()}
+ »
+ {_page_tab_edit}
+{/block}
+
+{block content}
+{include "clubTabs.latte", club => $club, page => $page, tab => $tab}
+
+
+
+{script "js/al_pages.js"}
+
+{/block}
diff --git a/Web/Presenters/templates/Notes/ClubHelp.latte b/Web/Presenters/templates/Notes/ClubHelp.latte
new file mode 100644
index 000000000..eb5c451ce
--- /dev/null
+++ b/Web/Presenters/templates/Notes/ClubHelp.latte
@@ -0,0 +1,132 @@
+{extends "../@layout.latte"}
+
+{block title}{_markup_help} — {$club->getName()}{/block}
+
+{block header}
+
{$club->getName()}
+ »
+
{_group_pages}
+ »
+ {_markup_help}
+{/block}
+
+{block content}
+{include "clubListTabs.latte", club => $club, mode => 'help'}
+
+
+
{_markup_help_intro}
+
+
+
+
+
+ | {_markup_help_col_syntax} |
+ {_markup_help_col_result} |
+ {_markup_help_col_descr} |
+
+
+
+
+ **text** |
+ text |
+ {_markup_help_bold} |
+
+
+ *text* |
+ text |
+ {_markup_help_italic} |
+
+
+ # Heading |
+ Heading |
+ {_markup_help_h1} |
+
+
+ ## Heading |
+ Heading |
+ {_markup_help_h2} |
+
+
+ ### Heading |
+ Heading |
+ {_markup_help_h3} |
+
+
+ - item |
+ |
+ {_markup_help_list} |
+
+
+ > quote |
+ quote |
+ {_markup_help_quote} |
+
+
+ [text](url) |
+ text |
+ {_markup_help_link} |
+
+
+  |
+ [{_markup_help_image}] |
+ {_markup_help_image} |
+
+
+ {="[[Page title]]"} |
+ Page title |
+ {_markup_help_wiki} |
+
+
+ {="[[Page title|label]]"} |
+ label |
+ {_markup_help_wiki_label} |
+
+
+
+ | A | B |
+|---|---|
+| 1 | 2 |
+ |
+
+
+ |
+ {_markup_help_table} |
+
+
+
+ | L | C | R |
+|:--|:-:|--:|
+| a | b | c |
+ |
+
+
+
+
+ | L |
+ C |
+ R |
+
+
+
+
+ | a |
+ b |
+ c |
+
+
+
+ |
+ {_markup_help_table_align} |
+
+
+
+
+
+{/block}
diff --git a/Web/Presenters/templates/Notes/ClubHistory.latte b/Web/Presenters/templates/Notes/ClubHistory.latte
new file mode 100644
index 000000000..70525b447
--- /dev/null
+++ b/Web/Presenters/templates/Notes/ClubHistory.latte
@@ -0,0 +1,50 @@
+{extends "../@layout.latte"}
+
+{block title}{_page_tab_history} — {$page->getTitle()}{/block}
+
+{block header}
+
{$club->getName()}
+ »
+
{$page->getTitle()}
+ »
+ {_page_tab_history}
+{/block}
+
+{block content}
+{include "clubTabs.latte", club => $club, page => $page, tab => $tab}
+
+
+
+
+
+
+ | {_page_history_col_title} |
+ {_page_history_col_bytes} |
+ {_page_history_col_time} |
+ {_page_history_col_author} |
+
+
+
+
+ |
+ {$rev->getTitle()}
+ |
+ {tr("page_history_bytes", strlen($rev->getSource()))} |
+ {$rev->getCreationTime()} |
+
+ {var $editor = $rev->getEditor()}
+ {if $editor}
+ {$editor->getCanonicalName()}
+ {else}
+ —
+ {/if}
+ |
+
+
+
+
{_page_no_revisions}
+
+
+
+{include "../components/paginator.latte", conf => $paginatorConf}
+{/block}
diff --git a/Web/Presenters/templates/Notes/ClubList.latte b/Web/Presenters/templates/Notes/ClubList.latte
new file mode 100644
index 000000000..da59d7ecf
--- /dev/null
+++ b/Web/Presenters/templates/Notes/ClubList.latte
@@ -0,0 +1,102 @@
+{extends "../@layout.latte"}
+
+{block title}{_group_pages} — {$club->getName()}{/block}
+
+{block header}
+
{$club->getName()} » {_group_pages}
+{/block}
+
+{block content}
+
+
+{include "clubListTabs.latte", club => $club, mode => 'list'}
+
+
+ {_group_pages_descr}
+
+
+
+
+
+ {tr("pages_count", $count)}
+ {tr("showing_x_y", $showingFrom, $showingTo)}
+
+ {if isset($thisUser) && $club->canCreatePages($thisUser)}
+
| {_create_new_page}
+ {/if}
+
+ {include "../components/paginator.latte", conf => $paginatorConf}
+
+
+
+ {_pages_none}
+ {if isset($thisUser) && $club->canCreatePages($thisUser)}
+
| {_create_new_page}
+ {/if}
+
+
+
+
+
+
+
+ {_pages_empty}
+ {if isset($thisUser) && $club->canCreatePages($thisUser)}
+
+
{_create_first_page}
+ {/if}
+
+
+
+ {include "../components/paginator.latte", conf => $bottomPaginatorConf}
+
+
+{if $paginatorConf->pageCount > 1}
+
+{/if}
+{/block}
diff --git a/Web/Presenters/templates/Notes/ClubRevision.latte b/Web/Presenters/templates/Notes/ClubRevision.latte
new file mode 100644
index 000000000..3c0fb517d
--- /dev/null
+++ b/Web/Presenters/templates/Notes/ClubRevision.latte
@@ -0,0 +1,31 @@
+{extends "../@layout.latte"}
+
+{block title}{_page_revision} — {$page->getTitle()}{/block}
+
+{block header}
+
{_page_tab_history}
+ »
+ {$revision->getCreationTime()}
+{/block}
+
+{block content}
+{include "clubTabs.latte", club => $club, page => $page, tab => $tab}
+
+
+ {$revision->getTitle()}
+
+
+ {$html|noescape}
+
+
+
+
+{/block}
diff --git a/Web/Presenters/templates/Notes/ClubView.latte b/Web/Presenters/templates/Notes/ClubView.latte
new file mode 100644
index 000000000..897d47517
--- /dev/null
+++ b/Web/Presenters/templates/Notes/ClubView.latte
@@ -0,0 +1,34 @@
+{extends "../@layout.latte"}
+
+{block title}{$page->getTitle()} — {$club->getName()}{/block}
+
+{block header}
+
{$club->getName()}
+ »
+
{_group_pages}
+ »
+ {$page->getTitle()}
+{/block}
+
+{block content}
+{include "clubTabs.latte", club => $club, page => $page, tab => $tab}
+
+
+ {$page->getTitle()}
+
+ {$page->getText($thisUser ?? null)|noescape}
+
+
+
+
+{/block}
diff --git a/Web/Presenters/templates/Notes/Create.latte b/Web/Presenters/templates/Notes/Create.latte
index fc659b552..10663356b 100644
--- a/Web/Presenters/templates/Notes/Create.latte
+++ b/Web/Presenters/templates/Notes/Create.latte
@@ -7,52 +7,118 @@
{/block}
{block content}
-
-
+
{script "js/node_modules/monaco-editor/min/vs/loader.js"}
{script "js/node_modules/requirejs/bin/r.js"}
+ {script "js/al_notes.js"}
+ {script "js/al_pages.js"}
{/block}
diff --git a/Web/Presenters/templates/Notes/Edit.latte b/Web/Presenters/templates/Notes/Edit.latte
index c9dcc9944..c3354d72b 100644
--- a/Web/Presenters/templates/Notes/Edit.latte
+++ b/Web/Presenters/templates/Notes/Edit.latte
@@ -12,26 +12,67 @@
{/block}
{block content}
-
-
- {script "js/node_modules/monaco-editor/min/vs/loader.js"}
- {script "js/node_modules/requirejs/bin/r.js"}
+
+ {if !$isMarkdown}
+ {script "js/node_modules/monaco-editor/min/vs/loader.js"}
+ {script "js/node_modules/requirejs/bin/r.js"}
+ {script "js/al_notes.js"}
+ {/if}
+ {script "js/al_pages.js"}
{/block}
diff --git a/Web/Presenters/templates/Notes/List.latte b/Web/Presenters/templates/Notes/List.latte
index 4b62f2513..cb99d243f 100644
--- a/Web/Presenters/templates/Notes/List.latte
+++ b/Web/Presenters/templates/Notes/List.latte
@@ -83,7 +83,7 @@
- {$dat->getText(750)|noescape}
+ {$dat->getText($thisUser ?? null)|noescape}
- {$note->getText()|noescape}
+ {$note->getText($thisUser ?? null)|noescape}
@@ -87,6 +87,7 @@
page => $cPage,
model => "notes",
parent => $note,
- showTitle => false}
+ showTitle => false,
+ readOnly => !(isset($thisUser) && $note->canBeCommentedBy($thisUser))}
{/block}
diff --git a/Web/Presenters/templates/Notes/clubListTabs.latte b/Web/Presenters/templates/Notes/clubListTabs.latte
new file mode 100644
index 000000000..7ec5e7aa9
--- /dev/null
+++ b/Web/Presenters/templates/Notes/clubListTabs.latte
@@ -0,0 +1,16 @@
+{var $isList = ($mode ?? '') === 'list'}
+{var $isHelp = ($mode ?? '') === 'help'}
+{var $isCreate = ($mode ?? '') === 'create'}
+
+
diff --git a/Web/Presenters/templates/Notes/clubTabs.latte b/Web/Presenters/templates/Notes/clubTabs.latte
new file mode 100644
index 000000000..44a227d76
--- /dev/null
+++ b/Web/Presenters/templates/Notes/clubTabs.latte
@@ -0,0 +1,23 @@
+{var $pageUrl = $page->getURL()}
+{var $isView = $tab === 'view'}
+{var $isEdit = $tab === 'edit'}
+{var $isHistory = $tab === 'history'}
+
+
diff --git a/Web/Presenters/templates/Notes/xhtmlToolbar.latte b/Web/Presenters/templates/Notes/xhtmlToolbar.latte
new file mode 100644
index 000000000..f956522d1
--- /dev/null
+++ b/Web/Presenters/templates/Notes/xhtmlToolbar.latte
@@ -0,0 +1,16 @@
+
diff --git a/Web/Presenters/templates/Report/content/note.latte b/Web/Presenters/templates/Report/content/note.latte
index f4f2e054b..87e2da637 100644
--- a/Web/Presenters/templates/Report/content/note.latte
+++ b/Web/Presenters/templates/Report/content/note.latte
@@ -12,7 +12,7 @@
- {$note->getText()|noescape}
+ {$note->getText($thisUser ?? null)|noescape}
{/block}
diff --git a/Web/Presenters/templates/_includeCSS.latte b/Web/Presenters/templates/_includeCSS.latte
index 61f70c929..f98e16b46 100644
--- a/Web/Presenters/templates/_includeCSS.latte
+++ b/Web/Presenters/templates/_includeCSS.latte
@@ -11,6 +11,7 @@
{css "css/notifications.css"}
{css "css/avatar-edit.css"}
{css "css/audios.css"}
+ {css "css/gpages.css"}
{if $isXmas}
{css "css/xmas.css"}
@@ -33,6 +34,7 @@
{css "css/notifications.css"}
{css "css/avatar-edit.css"}
{css "css/audios.css"}
+ {css "css/gpages.css"}
{css "css/mobile.css"}
{if $isXmas}
@@ -58,6 +60,7 @@
{css "css/nsfw-posts.css"}
{css "css/notifications.css"}
{css "css/audios.css"}
+ {css "css/gpages.css"}
{css "css/mobile.css"}
{if $isXmas}
diff --git a/Web/routes.yml b/Web/routes.yml
index ec7cf6efc..f0a81db97 100644
--- a/Web/routes.yml
+++ b/Web/routes.yml
@@ -129,8 +129,8 @@ routes:
handler: "Wall->rss"
- url: "/wall{num}/makePost"
handler: "Wall->makePost"
- - url: "/wall{num}/archive/manage"
- handler: "Wall->archiveBulk"
+ - url: "/wall{num}/archive/manage"
+ handler: "Wall->archiveBulk"
- url: "/wall{num}_{num}"
handler: "Wall->post"
- url: "/wall{num}_{num}/like"
@@ -269,6 +269,28 @@ routes:
handler: "Topics->edit"
- url: "/topic{num}_{num}/delete"
handler: "Topics->delete"
+ - url: "/notes/preview"
+ handler: "Notes->preview"
+ - url: "/notes-{num}"
+ handler: "Notes->list"
+ - url: "/notes-{num}/create"
+ handler: "Notes->create"
+ - url: "/notes-{num}/help"
+ handler: "Notes->help"
+ - url: "/note-{num}_{num}"
+ handler: "Notes->view"
+ - url: "/note-{num}_{num}/edit"
+ handler: "Notes->edit"
+ - url: "/note-{num}_{num}/delete"
+ handler: "Notes->delete"
+ - url: "/note-{num}_{num}/setMain"
+ handler: "Notes->setMain"
+ - url: "/note-{num}_{num}/access"
+ handler: "Notes->access"
+ - url: "/note-{num}_{num}/history"
+ handler: "Notes->history"
+ - url: "/note-{num}_{num}/history/{num}"
+ handler: "Notes->revision"
- url: "/im"
handler: "Messenger->index"
- url: "/im/sel{num}"
@@ -292,7 +314,7 @@ routes:
- url: "/note{num}_{num}"
handler: "Notes->view"
- url: "/notes/prerender"
- handler: "Notes->preView"
+ handler: "Notes->preview"
- url: "/notes/create"
handler: "Notes->create"
- url: "/note{num}_{num}/edit"
diff --git a/Web/static/css/gpages.css b/Web/static/css/gpages.css
new file mode 100644
index 000000000..842abc543
--- /dev/null
+++ b/Web/static/css/gpages.css
@@ -0,0 +1,458 @@
+/* Group Pages / Wiki WYSIWYG */
+.wysiwyg_inpt {
+ width: 100%;
+ max-width: 100%;
+ height: 520px;
+ overflow: auto;
+ outline: none;
+ box-sizing: border-box;
+ border: 1px solid #c6d4dc;
+ padding: 12px;
+ font-family: Tahoma, Verdana, Arial, sans-serif;
+ font-size: 11px;
+ resize: vertical;
+}
+
+.wysiwyg_inpt.page_source_compact {
+ height: 380px !important;
+}
+
+.wysiwyg_bbpanel {
+ background: #f0f0f0;
+ border: 1px solid #c6d4dc;
+ border-bottom: 0;
+ padding: 5px;
+ max-width: 100%;
+ width: 100%;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.wysiwyg_icphoto,
+.wysiwyg_icvideo,
+.wysiwyg_iclink,
+.wysiwyg_icsymbol,
+.wysiwyg_icbold,
+.wysiwyg_ici,
+.wysiwyg_icunderline,
+.wysiwyg_icpleft,
+.wysiwyg_icpcenter,
+.wysiwyg_icpright,
+.wysiwyg_icquote,
+.wysiwyg_iclist,
+.wysiwyg_ich1,
+.wysiwyg_ich2,
+.wysiwyg_ich3,
+.wysiwyg_icwiki,
+.wysiwyg_icsource,
+.wysiwyg_ictable {
+ float: left;
+ width: 22px;
+ height: 22px;
+ margin-right: 3px;
+ border: 1px solid #f0f0f0;
+ text-decoration: none;
+ color: transparent;
+ font-size: 0;
+ line-height: 0;
+ text-indent: -9999px;
+ overflow: hidden;
+ box-sizing: border-box;
+}
+
+.wysiwyg_iclink { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -158px -19px; }
+.wysiwyg_iclink:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -158px -19px; border: 1px solid #ddd; }
+
+.wysiwyg_icbold { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat 2px 1px; }
+.wysiwyg_icbold:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat 2px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_ici { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -19px 1px; }
+.wysiwyg_ici:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -19px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_icunderline { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -38px 1px; }
+.wysiwyg_icunderline:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -38px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_icpleft { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -119px 1px; }
+.wysiwyg_icpleft:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -119px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_icpcenter { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -139px 1px; }
+.wysiwyg_icpcenter:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -139px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_icpright { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -39px -39px; }
+.wysiwyg_icpright:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -39px -39px; border: 1px solid #ddd; }
+
+.wysiwyg_icquote { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -198px -19px; }
+.wysiwyg_icquote:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -198px -19px; border: 1px solid #ddd; }
+
+.wysiwyg_iclist { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -158px 1px; }
+.wysiwyg_iclist:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -158px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_ich1 { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -238px 1px; }
+.wysiwyg_ich1:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -238px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_ich2 { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -258px 1px; }
+.wysiwyg_ich2:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -258px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_ich3 { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -278px 1px; }
+.wysiwyg_ich3:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -278px 1px; border: 1px solid #ddd; }
+
+.wysiwyg_icphoto { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -238px -19px; }
+.wysiwyg_icphoto:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -238px -19px; border: 1px solid #ddd; }
+
+.wysiwyg_icwiki { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -178px -39px; }
+.wysiwyg_icwiki:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -178px -39px; border: 1px solid #ddd; }
+
+.wysiwyg_icsource { background: url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -158px -39px; }
+.wysiwyg_icsource:hover { background: #fff url("/assets/packages/static/openvk/img/icons/wysiwyg.gif") no-repeat -158px -39px; border: 1px solid #ddd; }
+
+.wysiwyg_ictable {
+ background:
+ linear-gradient(#7a8fa8, #7a8fa8) 5px 5px / 12px 1px no-repeat,
+ linear-gradient(#7a8fa8, #7a8fa8) 5px 10px / 12px 1px no-repeat,
+ linear-gradient(#7a8fa8, #7a8fa8) 5px 15px / 12px 1px no-repeat,
+ linear-gradient(#7a8fa8, #7a8fa8) 5px 5px / 1px 11px no-repeat,
+ linear-gradient(#7a8fa8, #7a8fa8) 11px 5px / 1px 11px no-repeat,
+ linear-gradient(#7a8fa8, #7a8fa8) 16px 5px / 1px 11px no-repeat;
+}
+.wysiwyg_ictable:hover {
+ background-color: #fff;
+ border: 1px solid #ddd;
+ background-image:
+ linear-gradient(#7a8fa8, #7a8fa8),
+ linear-gradient(#7a8fa8, #7a8fa8),
+ linear-gradient(#7a8fa8, #7a8fa8),
+ linear-gradient(#7a8fa8, #7a8fa8),
+ linear-gradient(#7a8fa8, #7a8fa8),
+ linear-gradient(#7a8fa8, #7a8fa8);
+ background-size: 12px 1px, 12px 1px, 12px 1px, 1px 11px, 1px 11px, 1px 11px;
+ background-position: 5px 5px, 5px 10px, 5px 15px, 5px 5px, 11px 5px, 16px 5px;
+ background-repeat: no-repeat;
+}
+
+.wysiwyg_quote {
+ padding: 10px;
+ background: #f0f0f0;
+ border-left: 10px solid #4274a4;
+}
+
+/* Pages UI */
+.pages_tabs_bar,
+.pages_editor_tabs {
+ background: #597da3;
+ padding: 6px 8px;
+ margin-bottom: 10px;
+ overflow: hidden;
+}
+
+.pages_tab,
+.pages_editor_tab {
+ color: #dae2e8;
+ text-decoration: none;
+ margin-right: 14px;
+ font-weight: bold;
+ font-size: 11px;
+}
+
+.pages_tab.active,
+.pages_editor_tab.active {
+ color: #fff;
+ border-bottom: 2px solid #fff;
+ padding-bottom: 2px;
+}
+
+.pages_tab_right {
+ float: right;
+ margin-right: 0;
+}
+
+.pages_info_box {
+ background: #f7f7f7;
+ border: 1px solid #dae2e8;
+ border-left: 0;
+ border-right: 0;
+ padding: 10px;
+ margin: 0 -10px 10px;
+ width: 627px;
+ box-sizing: border-box;
+ font-size: 11px;
+}
+
+.pages_action_bar {
+ margin: 8px 0 12px;
+ font-size: 11px;
+}
+
+.pages_summary_bar {
+ margin: 0 -10px 0;
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
+.pages_summary_bar .summary {
+ font-weight: bold;
+}
+
+.pages_summary_bar .showing_x_y_text {
+ font-weight: normal;
+ color: #000;
+}
+
+.pages_summary_sep {
+ color: #000;
+ font-weight: normal;
+ margin: 0 2px;
+}
+
+.pages_summary_bar .paginator {
+ float: none;
+ margin-top: 0;
+}
+
+.pages_list .paginator.paginator-at-bottom {
+ float: right;
+}
+
+.pages_list_item {
+ display: flex;
+ padding: 10px 0;
+ border-bottom: 1px solid #e7e7e7;
+}
+
+.pages_list_icon {
+ width: 22px;
+ height: 22px;
+ margin: 2px 10px 0 0;
+ flex-shrink: 0;
+ background: url("../img/note_icon.png") no-repeat center;
+}
+
+.pages_list_body {
+ flex: 1;
+}
+
+.pages_list_title {
+ font-weight: bold;
+ margin-bottom: 4px;
+}
+
+.pages_list_meta {
+ color: #777;
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.pages_list_actions {
+ margin-top: 6px;
+ font-size: 11px;
+}
+
+.pages_main_badge {
+ color: #777;
+ font-weight: normal;
+ margin-left: 6px;
+ font-size: 11px;
+}
+
+.page_title_input {
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ margin-bottom: 0;
+ border: 1px solid #c6d4dc;
+ border-bottom: 0;
+ padding: 7px 8px;
+ font-size: 13px;
+ font-weight: bold;
+}
+
+.page_edit_form {
+ margin-top: 0;
+ margin-bottom: 10px;
+ margin-left: -10px;
+ margin-right: -10px;
+}
+
+.note_format_row {
+ padding: 8px 12px;
+ border: 1px solid #c6d4dc;
+ border-bottom: 0;
+ background: #f7f7f7;
+ font-size: 11px;
+}
+
+.note_format_row label {
+ margin-left: 10px;
+}
+
+.page_edit_form .wysiwyg_bbpanel,
+.page_edit_form .wysiwyg_inpt,
+.page_edit_form .page_preview_area {
+ max-width: 100%;
+ width: 100%;
+}
+
+.page_source_area {
+ display: block;
+}
+
+.page_preview_area {
+ width: 100%;
+ max-width: 100%;
+ min-height: 200px;
+ border: 1px solid #c6d4dc;
+ padding: 10px;
+ box-sizing: border-box;
+ background: #fff;
+}
+
+.page_edit_footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ margin-top: 14px;
+ margin-bottom: 6px;
+ padding: 2px 10px 6px;
+}
+
+.page_edit_footer_right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.page_keep_revisions {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: #45688E;
+ margin-right: 4px;
+ white-space: nowrap;
+}
+
+.page_edit_footer_right a {
+ margin-right: 0;
+}
+
+.wiki_page_content {
+ padding: 10px;
+}
+
+.wiki_page_title {
+ margin: 0 0 10px;
+ font-size: 16px;
+}
+
+.wiki_page_body {
+ line-height: 1.45;
+ word-wrap: break-word;
+}
+
+.wiki_page_body img {
+ max-width: 100%;
+}
+
+.wiki_page_body a.wiki-missing {
+ color: #c00;
+}
+
+.wiki_page_body table.wiki_md_table,
+.page_preview_area table.wiki_md_table,
+.wiki_page_body table,
+.page_preview_area table {
+ border-collapse: collapse;
+ margin: 10px 0;
+ max-width: 100%;
+ font-size: 11px;
+}
+
+.wiki_page_body table.wiki_md_table th,
+.wiki_page_body table.wiki_md_table td,
+.page_preview_area table.wiki_md_table th,
+.page_preview_area table.wiki_md_table td,
+.wiki_page_body table th,
+.wiki_page_body table td,
+.page_preview_area table th,
+.page_preview_area table td {
+ border: 1px solid #c0cad5;
+ padding: 5px 8px;
+ vertical-align: top;
+}
+
+.wiki_page_body table.wiki_md_table th,
+.page_preview_area table.wiki_md_table th,
+.wiki_page_body table th,
+.page_preview_area table th {
+ background: #f0f2f5;
+ font-weight: bold;
+}
+
+.pages_help_table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+}
+
+.pages_help_table th {
+ text-align: left;
+ padding: 6px 8px;
+ border-bottom: 1px solid #c0c0c0;
+ background: #f0f0f0;
+ font-weight: bold;
+}
+
+.pages_help_table td {
+ padding: 8px;
+ border-bottom: 1px solid #eee;
+ vertical-align: middle;
+}
+
+.pages_help_table code {
+ background: #f5f5f5;
+ padding: 2px 6px;
+ border: 1px solid #e0e0e0;
+ border-radius: 2px;
+ font-family: Consolas, "Courier New", monospace;
+ white-space: nowrap;
+}
+
+.pages_help_table td:first-child {
+ width: 30%;
+}
+
+.pages_help_table td:nth-child(2) {
+ width: 30%;
+}
+
+.pages_history_item {
+ padding: 8px 10px;
+ border-bottom: 1px solid #e7e7e7;
+}
+
+.pages_history_table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+}
+
+.pages_history_table th {
+ text-align: left;
+ padding: 6px 8px;
+ border-bottom: 1px solid #c0c0c0;
+ background: #f0f0f0;
+ font-weight: bold;
+}
+
+.pages_history_table td {
+ padding: 8px;
+ border-bottom: 1px solid #eee;
+ vertical-align: middle;
+}
+
+.pages_history_table td:nth-child(2) {
+ white-space: nowrap;
+ color: #777;
+}
diff --git a/Web/static/css/main.css b/Web/static/css/main.css
index 39ea4f1b1..12ca6f8bf 100644
--- a/Web/static/css/main.css
+++ b/Web/static/css/main.css
@@ -858,6 +858,10 @@ input[type="number"] {
background-position: 0px 0px;
}
+.section_empty a.pages::before {
+ background-position: 0px -120px;
+}
+
.content-withouttop {
padding-top: 0;
}
diff --git a/Web/static/img/icons/wysiwyg.gif b/Web/static/img/icons/wysiwyg.gif
new file mode 100644
index 000000000..2c52b4f43
Binary files /dev/null and b/Web/static/img/icons/wysiwyg.gif differ
diff --git a/Web/static/js/al_notes.js b/Web/static/js/al_notes.js
new file mode 100644
index 000000000..73f2cb527
--- /dev/null
+++ b/Web/static/js/al_notes.js
@@ -0,0 +1,78 @@
+window.OpenVKNotesToolbar = (function () {
+ var SNIPPETS = {
+ bold: ["", "", "text"],
+ italic: ["", "", "text"],
+ underline: ['', "", "text"],
+ left: ['\n', "\n
", "text"],
+ center: ['\n', "\n
", "text"],
+ right: ['\n', "\n
", "text"],
+ list: ["", "item"],
+ h3: ["", "
", "Heading"],
+ h4: ["", "
", "Heading"],
+ h5: ["", "
", "Heading"],
+ quote: ["", "
", "quote"],
+ link: ['', "", "text"],
+ image: ['
', "alt"],
+ table: [
+ "\n\n\n| Header | Header |
\n\n\n| ",
+ " | Cell |
\n\n
\n",
+ "Cell"
+ ]
+ };
+
+ function getEditor() {
+ return window._editor || null;
+ }
+
+ function insertSnippet(before, after, emptyFallback) {
+ var editor = getEditor();
+ if (!editor) {
+ return;
+ }
+
+ var selection = editor.getSelection();
+ var model = editor.getModel();
+ if (!selection || !model) {
+ return;
+ }
+
+ var selected = model.getValueInRange(selection);
+ var inner = selected.length ? selected : (emptyFallback || "");
+ var text = before + inner + (after || "");
+
+ editor.executeEdits("notes-toolbar", [{
+ range: selection,
+ text: text,
+ forceMoveMarkers: true
+ }]);
+ editor.focus();
+ }
+
+ function handleClick(e) {
+ var link = e.target.closest("#note_toolbar a[data-action]");
+ if (!link) {
+ return;
+ }
+
+ e.preventDefault();
+ var action = link.getAttribute("data-action");
+ var snippet = SNIPPETS[action];
+ if (!snippet) {
+ return;
+ }
+
+ insertSnippet(snippet[0], snippet[1], snippet[2] || "");
+ }
+
+ function init() {
+ if (document.body.dataset.notesToolbarBound === "1") {
+ return;
+ }
+ document.body.dataset.notesToolbarBound = "1";
+ document.addEventListener("click", handleClick);
+ }
+
+ return { init: init, insertSnippet: insertSnippet };
+})();
+
+window.OpenVKNotesToolbar.init();
diff --git a/Web/static/js/al_pages.js b/Web/static/js/al_pages.js
new file mode 100644
index 000000000..4477850db
--- /dev/null
+++ b/Web/static/js/al_pages.js
@@ -0,0 +1,574 @@
+window.OpenVKPages = (function () {
+ var bound = false;
+ var previewLoading = false;
+
+ function getTextarea() {
+ return document.getElementById("page_source");
+ }
+
+ function previewLabel(editing) {
+ return editing ? tr("page_tab_edit") : tr("page_preview");
+ }
+
+ function setPreviewButtons(editing) {
+ var btn = document.getElementById("page_preview_btn");
+ if (btn) {
+ btn.textContent = previewLabel(editing);
+ }
+ var icon = document.querySelector("#page_toolbar a[data-action='preview']");
+ if (icon) {
+ icon.classList.toggle("wysiwyg_active", editing);
+ }
+ }
+
+ function getTitleInput() {
+ return document.querySelector("#page_edit_form input[name='title'], #noteFactory input[name='name'], #page_edit_form input[name='name']");
+ }
+
+ function shrinkSource(force) {
+ var ta = getTextarea();
+ if (!ta) {
+ return;
+ }
+ if (!force && ta.dataset.pagesLocked === "1") {
+ return;
+ }
+
+ ta.dataset.pagesLocked = "1";
+ ta.classList.add("page_source_compact");
+ ta.style.height = "380px";
+ }
+
+ function setupSourceArea() {
+ var ta = getTextarea();
+ if (!ta) {
+ return;
+ }
+
+ if (ta.value.length > 0) {
+ shrinkSource(true);
+ return;
+ }
+
+ ta.classList.remove("page_source_compact");
+ ta.style.height = "";
+ ta.dataset.pagesLocked = "0";
+
+ if (ta.dataset.pagesShrinkBound === "1") {
+ return;
+ }
+ ta.dataset.pagesShrinkBound = "1";
+ ta.addEventListener("input", function onFirstInput() {
+ shrinkSource(true);
+ ta.removeEventListener("input", onFirstInput);
+ });
+ }
+
+ function insertMarkdown(pattern) {
+ var ta = getTextarea();
+ if (!ta || !pattern) {
+ return;
+ }
+
+ var start = ta.selectionStart;
+ var end = ta.selectionEnd;
+ var value = ta.value;
+ var selected = value.substring(start, end);
+ var parts = pattern.split("|");
+ var before = parts[0] || "";
+ var after = parts.length > 1 ? parts.slice(1).join("|") : "";
+ var insertion;
+
+ if (pattern.indexOf("|") === -1) {
+ insertion = before + selected;
+ ta.value = value.substring(0, start) + insertion + value.substring(end);
+ ta.focus();
+ ta.selectionStart = ta.selectionEnd = start + insertion.length;
+ shrinkSource(true);
+ return;
+ }
+
+ insertion = before + (selected || "") + after;
+ ta.value = value.substring(0, start) + insertion + value.substring(end);
+ ta.focus();
+ if (selected) {
+ ta.selectionStart = start;
+ ta.selectionEnd = start + insertion.length;
+ } else {
+ ta.selectionStart = ta.selectionEnd = start + before.length;
+ }
+ shrinkSource(true);
+ }
+
+ function insertPhoto() {
+ if (typeof CMessageBox === "undefined" || !window.OVKAPI || !window.OVKAPI.call) {
+ insertMarkdown("");
+ return;
+ }
+
+ var preview = document.getElementById("page_preview");
+ var club = Number((preview && preview.getAttribute("data-club")) || 0);
+ var albumOwner = club ? -Math.abs(club) : window.openvk.current_id;
+ var photosPerPage = 23;
+
+ var msg = new CMessageBox({
+ title: tr("select_photo"),
+ body:
+ "" +
+ "
" +
+ "" +
+ "
" +
+ "
" +
+ "
" + tr("is_x_photos", 0) + "
" +
+ "
" +
+ "
" +
+ "
",
+ buttons: [tr("close")],
+ callbacks: [Function.noop],
+ unique_name: "page_photo_picker"
+ });
+
+ msg.getNode().attr("style", "width: 630px;");
+ msg.getNode().find(".ovk-diag-body").attr("style", "height:335px;padding:0px;");
+
+ async function receivePhotos(page, album) {
+ album = album || 0;
+ u("#gif_loader").remove();
+ u("#attachment_insert").append("");
+ var insertPlace = u("#attachment_insert .photosList");
+ var photos;
+
+ try {
+ if (album == 0) {
+ photos = await window.OVKAPI.call("photos.getAll", {
+ owner_id: window.openvk.current_id,
+ photo_sizes: 1,
+ count: photosPerPage,
+ offset: page * photosPerPage
+ });
+ } else {
+ photos = await window.OVKAPI.call("photos.get", {
+ owner_id: albumOwner,
+ album_id: album,
+ photo_sizes: 1,
+ count: photosPerPage,
+ offset: page * photosPerPage
+ });
+ }
+ } catch (e) {
+ u("#attachment_insert_count h4").html(tr("is_x_photos", -1));
+ u("#gif_loader").remove();
+ insertPlace.html("Invalid album");
+ return;
+ }
+
+ u("#attachment_insert_count h4").html(tr("is_x_photos", photos.count));
+ u("#gif_loader").remove();
+ var pagesCount = Math.ceil(Number(photos.count) / photosPerPage);
+ (photos.items || []).forEach(function (photo) {
+ var ownerId = Number(photo.owner_id);
+ var photoId = Number(photo.id);
+ if (!Number.isFinite(ownerId) || !Number.isFinite(photoId)) {
+ return;
+ }
+
+ insertPlace.append(
+ "" +
+ "
" +
+ ""
+ );
+ });
+
+ if (page < pagesCount - 1) {
+ insertPlace.append(
+ "" +
+ "" + tr("show_more") + "" +
+ "
"
+ );
+ }
+ }
+
+ u(".ovk-diag-body .attachment_selector").on("change", ".topGrayBlock #albumSelect", function (ev) {
+ u("#attachment_insert .photosList").html("");
+ receivePhotos(0, ev.target.value);
+ });
+
+ u(".ovk-diag-body .attachment_selector").on("click", "#show_more", async function (ev) {
+ var target = u(ev.target).closest("#show_more");
+ target.addClass("lagged");
+ await receivePhotos(Number(target.nodes[0].dataset.page), u(".topGrayBlock #albumSelect").nodes[0].value);
+ target.remove();
+ });
+
+ u(".ovk-diag-body .attachment_selector").on("click", ".album-photo", function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var id = u(ev.target).closest(".album-photo").nodes[0].dataset.attachmentdata;
+ insertMarkdown("");
+ msg.close();
+ });
+
+ receivePhotos(0);
+ window.OVKAPI.call("photos.getAlbums", { owner_id: albumOwner }).then(function (albums) {
+ (albums.items || []).forEach(function (item) {
+ u(".ovk-diag-body #albumSelect").append(
+ ""
+ );
+ });
+ }).catch(Function.noop);
+ }
+
+ function wrapAlign(align) {
+ var ta = getTextarea();
+ if (!ta) {
+ return;
+ }
+ var start = ta.selectionStart;
+ var end = ta.selectionEnd;
+ var selected = ta.value.substring(start, end) || "text";
+ var wrapped = '\n\n' + selected + '\n\n
';
+ ta.value = ta.value.substring(0, start) + wrapped + ta.value.substring(end);
+ ta.focus();
+ shrinkSource(true);
+ }
+
+ function insertTable() {
+ var ta = getTextarea();
+ if (!ta) {
+ return;
+ }
+
+ var start = ta.selectionStart;
+ var end = ta.selectionEnd;
+ var snippet = "| Header | Header |\n| --- | --- |\n| Cell | Cell |\n";
+ if (start > 0 && ta.value.charAt(start - 1) !== "\n") {
+ snippet = "\n" + snippet;
+ }
+
+ ta.value = ta.value.substring(0, start) + snippet + ta.value.substring(end);
+ ta.focus();
+ ta.selectionStart = ta.selectionEnd = start + snippet.length;
+ shrinkSource(true);
+ }
+
+ function currentFormat(preview) {
+ var checked = document.querySelector("input[name='format']:checked");
+ if (checked) {
+ return checked.value;
+ }
+ var hidden = document.querySelector("input[name='format']");
+ if (hidden && hidden.type === "hidden") {
+ return hidden.value;
+ }
+ return (preview && preview.getAttribute("data-format")) || "1";
+ }
+
+ function currentSource(format) {
+ if (format === "0" && window._editor) {
+ return window._editor.getValue();
+ }
+ var ta = getTextarea();
+ return ta ? ta.value : "";
+ }
+
+ function showEditorSurfaces() {
+ var preview = document.getElementById("page_preview");
+ var format = currentFormat(preview);
+ var ta = getTextarea();
+ var md = document.getElementById("note_md_editor");
+ var html = document.getElementById("note_html_editor");
+ var monaco = document.getElementById("editor");
+ if (format === "0") {
+ if (html) {
+ html.style.display = "block";
+ }
+ if (monaco) {
+ monaco.style.display = "block";
+ }
+ if (md) {
+ md.style.display = "none";
+ }
+ } else {
+ if (md) {
+ md.style.display = "block";
+ }
+ if (ta) {
+ ta.style.display = "block";
+ }
+ if (html) {
+ html.style.display = "none";
+ }
+ }
+ }
+
+ function hideEditorSurfaces() {
+ var ta = getTextarea();
+ var monaco = document.getElementById("editor");
+ var html = document.getElementById("note_html_editor");
+ if (ta) {
+ ta.style.display = "none";
+ }
+ if (monaco) {
+ monaco.style.display = "none";
+ }
+ if (html) {
+ html.style.display = "none";
+ }
+ }
+
+ function exitPreview(preview) {
+ preview.style.display = "none";
+ preview.dataset.previewing = "0";
+ showEditorSurfaces();
+ setPreviewButtons(false);
+ }
+
+ function togglePreview() {
+ var preview = document.getElementById("page_preview");
+ if (!preview || previewLoading) {
+ return;
+ }
+
+ if (preview.dataset.previewing === "1") {
+ exitPreview(preview);
+ return;
+ }
+
+ var format = currentFormat(preview);
+ var source = currentSource(format);
+ var club = preview.getAttribute("data-club") || "";
+ var csrf = document.querySelector('meta[name="csrf"]');
+ var hash = csrf ? csrf.getAttribute("value") : "";
+ var body = "source=" + encodeURIComponent(source) +
+ "&html=" + encodeURIComponent(source) +
+ "&format=" + encodeURIComponent(format) +
+ "&club=" + encodeURIComponent(club) +
+ "&hash=" + encodeURIComponent(hash);
+
+ previewLoading = true;
+ fetch("/notes/preview", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body,
+ credentials: "same-origin"
+ }).then(function (r) {
+ return r.text().then(function (html) {
+ return { ok: r.ok, html: html };
+ });
+ }).then(function (res) {
+ if (!res.ok) {
+ exitPreview(preview);
+ return;
+ }
+ preview.innerHTML = res.html;
+ preview.style.display = "block";
+ preview.dataset.previewing = "1";
+ hideEditorSurfaces();
+ setPreviewButtons(true);
+ }).catch(function () {
+ exitPreview(preview);
+ }).then(function () {
+ previewLoading = false;
+ });
+ }
+
+ function accessRadio(name, value, current, label) {
+ var checked = String(current) === String(value) ? " checked" : "";
+ return "";
+ }
+
+ function buildAccessBody(viewVal, editVal, commentVal) {
+ return "" +
+ "" + tr("page_who_can_view") + "" +
+ accessRadio("mb_view_access", 0, viewVal, tr("page_access_everyone")) +
+ accessRadio("mb_view_access", 1, viewVal, tr("page_access_members")) +
+ accessRadio("mb_view_access", 2, viewVal, tr("page_access_admins")) +
+ "
" +
+ "" +
+ "" + tr("page_who_can_edit") + "" +
+ accessRadio("mb_edit_access", 0, editVal, tr("page_access_everyone")) +
+ accessRadio("mb_edit_access", 1, editVal, tr("page_access_members")) +
+ accessRadio("mb_edit_access", 2, editVal, tr("page_access_admins")) +
+ "
" +
+ "" +
+ "" + tr("page_who_can_comment") + "" +
+ accessRadio("mb_comment_access", 0, commentVal, tr("page_access_everyone")) +
+ accessRadio("mb_comment_access", 1, commentVal, tr("page_access_members")) +
+ accessRadio("mb_comment_access", 2, commentVal, tr("page_access_admins")) +
+ "
";
+ }
+
+ function readAccessChoice(name) {
+ var el = document.querySelector('.ovk-diag-body input[name="' + name + '"]:checked');
+ return el ? el.value : null;
+ }
+
+ function showAccessModal() {
+ var btn = document.getElementById("page_access_btn");
+ if (!btn || typeof CMessageBox === "undefined") {
+ return;
+ }
+
+ var viewHidden = document.getElementById("page_view_access");
+ var editHidden = document.getElementById("page_edit_access");
+ var commentHidden = document.getElementById("page_comment_access");
+ var isCreate = !!(viewHidden && editHidden);
+ var viewVal = isCreate ? viewHidden.value : (btn.getAttribute("data-view") || "0");
+ var editVal = isCreate ? editHidden.value : (btn.getAttribute("data-edit") || "2");
+ var commentVal = isCreate
+ ? (commentHidden ? commentHidden.value : "0")
+ : (btn.getAttribute("data-comment") || "0");
+ var accessUrl = btn.getAttribute("data-access-url");
+
+ var msg = new CMessageBox({
+ title: tr("page_access_title"),
+ body: buildAccessBody(viewVal, editVal, commentVal),
+ buttons: [tr("save_changes"), tr("cancel")],
+ close_on_buttons: false,
+ unique_name: "page_access_dialog",
+ callbacks: [
+ function () {
+ var view = readAccessChoice("mb_view_access");
+ var edit = readAccessChoice("mb_edit_access");
+ var comment = readAccessChoice("mb_comment_access");
+ if (view === null || edit === null || comment === null) {
+ return;
+ }
+
+ if (isCreate) {
+ viewHidden.value = view;
+ editHidden.value = edit;
+ if (commentHidden) {
+ commentHidden.value = comment;
+ }
+ msg.close();
+ return;
+ }
+
+ if (!accessUrl) {
+ msg.close();
+ return;
+ }
+
+ var form = document.createElement("form");
+ form.method = "POST";
+ form.action = accessUrl;
+ form.style.display = "none";
+
+ function addField(name, value) {
+ var input = document.createElement("input");
+ input.type = "hidden";
+ input.name = name;
+ input.value = value;
+ form.appendChild(input);
+ }
+
+ var csrf = document.querySelector('meta[name="csrf"]');
+ addField("hash", csrf ? csrf.getAttribute("value") : "");
+ addField("view_access", view);
+ addField("edit_access", edit);
+ addField("comment_access", comment);
+ document.body.appendChild(form);
+ form.submit();
+ },
+ function () {
+ msg.close();
+ }
+ ]
+ });
+
+ if (msg && msg.getNode) {
+ msg.getNode().find(".ovk-diag-body").attr("style", "padding:15px;");
+ msg.getNode().attr("style", "width:420px;");
+ }
+ }
+
+ function bindTitleValidation(form) {
+ if (!form || form.dataset.titleValidateBound === "1") {
+ return;
+ }
+ form.dataset.titleValidateBound = "1";
+ form.addEventListener("submit", function (e) {
+ var formatEl = document.querySelector("input[name='format']:checked") || document.querySelector("input[name='format']");
+ if (formatEl && formatEl.value === "0" && window._editor) {
+ var html = document.querySelector("textarea[name='html']");
+ if (html) {
+ html.value = window._editor.getValue();
+ }
+ }
+
+ var title = getTitleInput();
+ if (!title) {
+ return;
+ }
+ title.value = title.value.trim();
+ if (title.value === "") {
+ e.preventDefault();
+ title.focus();
+ fastError(tr("page_no_title"));
+ }
+ });
+ }
+
+ function init() {
+ setupSourceArea();
+ bindTitleValidation(document.getElementById("page_edit_form"));
+ bindTitleValidation(document.getElementById("noteFactory"));
+
+ if (bound) {
+ return;
+ }
+ bound = true;
+
+ document.addEventListener("click", function (e) {
+ var target = e.target;
+ if (!target || !target.closest) {
+ return;
+ }
+
+ if (target.closest("#page_preview_btn")) {
+ e.preventDefault();
+ togglePreview();
+ return;
+ }
+
+ if (target.closest("#page_access_btn")) {
+ e.preventDefault();
+ e.stopPropagation();
+ showAccessModal();
+ return;
+ }
+
+ var toolbarLink = target.closest("#page_toolbar a");
+ if (toolbarLink) {
+ e.preventDefault();
+ if (toolbarLink.getAttribute("data-action") === "preview") {
+ togglePreview();
+ return;
+ }
+ if (toolbarLink.getAttribute("data-action") === "photo") {
+ insertPhoto();
+ return;
+ }
+ if (toolbarLink.getAttribute("data-action") === "table") {
+ insertTable();
+ return;
+ }
+ var align = toolbarLink.getAttribute("data-align");
+ if (align) {
+ wrapAlign(align);
+ return;
+ }
+ insertMarkdown(toolbarLink.getAttribute("data-md") || "");
+ }
+ });
+ }
+
+ return { init: init, showAccessModal: showAccessModal, shrinkSource: shrinkSource };
+})();
+
+window.OpenVKPages.init();
diff --git a/install/sqls/00065-notes-as-wiki.sql b/install/sqls/00065-notes-as-wiki.sql
new file mode 100644
index 000000000..02fb9c974
--- /dev/null
+++ b/install/sqls/00065-notes-as-wiki.sql
@@ -0,0 +1,26 @@
+-- Club notes (owner = -group_id): wiki fields, optional revisions, and materials flag.
+
+ALTER TABLE `groups` ADD COLUMN `pages` TINYINT(1) NOT NULL DEFAULT 0 AFTER `everyone_can_upload_audios`;
+
+ALTER TABLE `notes`
+ ADD COLUMN `format` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `source`,
+ ADD COLUMN `created_by` BIGINT(20) UNSIGNED NULL DEFAULT NULL AFTER `owner`,
+ ADD COLUMN `is_main` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `deleted`,
+ ADD COLUMN `view_access` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `is_main`,
+ ADD COLUMN `edit_access` TINYINT(1) UNSIGNED NOT NULL DEFAULT 2 AFTER `view_access`,
+ ADD COLUMN `comment_access` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `edit_access`,
+ ADD COLUMN `keep_revisions` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 AFTER `comment_access`;
+
+ALTER TABLE `notes`
+ ADD KEY `owner_deleted_main` (`owner`, `deleted`, `is_main`);
+
+CREATE TABLE IF NOT EXISTS `note_revisions` (
+ `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `note` BIGINT(20) UNSIGNED NOT NULL,
+ `editor` BIGINT(20) UNSIGNED NOT NULL,
+ `title` VARCHAR(256) NOT NULL,
+ `source` LONGTEXT NOT NULL,
+ `created` BIGINT(20) UNSIGNED NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `note_created` (`note`, `created`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
diff --git a/locales/en.strings b/locales/en.strings
index 2ec5d4533..0cbe9561d 100644
--- a/locales/en.strings
+++ b/locales/en.strings
@@ -1525,6 +1525,97 @@
"everyone_can_upload_audios" = "Everyone can upload audios";
"display_list_of_topics_above_wall" = "Display a list of topics above the wall";
+"materials" = "Materials";
+"materials_disabled" = "Disabled";
+"materials_open" = "Open";
+"materials_limited" = "Limited";
+"materials_hint" = "Enabling Materials places a pages block on the community main page. When materials are open, all members can create pages. When limited — only managers can.";
+"error_invalid_pages_value" = "Invalid materials section value.";
+"group_pages" = "Pages";
+"group_pages_main" = "Community main page";
+"all_pages" = "all pages";
+"group_pages_descr" = "Your community can contain additional pages with information. You can customize their structure and content on this tab.";
+"edit_group_pages" = "Edit community pages";
+"create_first_page" = "Create the first page";
+"create_new_page" = "Create a new page";
+"page_new_tab" = "New page";
+"pages_only_one" = "The only page";
+"note_keep_revisions" = "Keep edit history";
+"pages_none" = "No pages";
+"pages_empty" = "No pages yet";
+"pages_count_zero" = "No pages";
+"pages_count_one" = "$1 page";
+"pages_count_few" = "$1 pages";
+"pages_count_many" = "$1 pages";
+"pages_count_other" = "$1 pages";
+"page_is_main" = "(main)";
+"page_created" = "Created";
+"page_updated" = "Updated";
+"page_make_main" = "Make main";
+"page_set_main_succ" = "Page was set as main.";
+"page_deleted" = "Page deleted";
+"page_deleted_descr" = "The page was successfully deleted.";
+"page_delete_confirm" = "Delete this page?";
+"page_no_title" = "Please enter a page title.";
+"page_title_too_long" = "Page title is too long.";
+"page_title_exists" = "A page with this title already exists.";
+"page_title_placeholder" = "Page title";
+"page_source_placeholder" = "Page text (Markdown)…";
+"page_tab_view" = "View";
+"page_tab_edit" = "Edit";
+"page_tab_history" = "History";
+"page_preview" = "Preview";
+"save_page" = "Save page";
+"page_access" = "Page access";
+"page_access_title" = "Access to this page";
+"page_who_can_view" = "Who can view this page?";
+"page_who_can_edit" = "Who can edit this page?";
+"page_who_can_comment" = "Who can comment on this page?";
+"page_access_everyone" = "All users";
+"page_access_members" = "Community members only";
+"page_access_admins" = "Community managers only";
+"page_access_friends" = "Friends only";
+"page_access_owner" = "Only the author";
+"note_format" = "Format";
+"note_format_markdown" = "Markdown";
+"note_format_html" = "HTML";
+"page_access_saved" = "Access settings saved.";
+"page_access_denied" = "You do not have permission to view this page.";
+"page_no_revisions" = "No revisions yet.";
+"page_history_col_title" = "Title";
+"page_history_col_bytes" = "Size";
+"page_history_col_time" = "Modified";
+"page_history_col_author" = "Author";
+"page_history_bytes_zero" = "$1 bytes";
+"page_history_bytes_one" = "$1 byte";
+"page_history_bytes_few" = "$1 bytes";
+"page_history_bytes_many" = "$1 bytes";
+"page_history_bytes_other" = "$1 bytes";
+"page_revision" = "Revision";
+"page_restore" = "Restore this version";
+"page_restore_confirm" = "Restore this page version?";
+"page_restored" = "Page restored from the selected revision.";
+"back_to_pages" = "Back to pages";
+"to_community" = "To community";
+"markup_help" = "Markup help";
+"markup_help_intro" = "Community pages use Markdown. Basic syntax:";
+"markup_help_col_syntax" = "Markup";
+"markup_help_col_result" = "Result";
+"markup_help_col_descr" = "Description";
+"markup_help_bold" = "Bold text";
+"markup_help_italic" = "Italic text";
+"markup_help_h1" = "Heading level 1";
+"markup_help_h2" = "Heading level 2";
+"markup_help_h3" = "Heading level 3";
+"markup_help_list" = "Bulleted list";
+"markup_help_quote" = "Quote";
+"markup_help_link" = "Link";
+"markup_help_image" = "Image";
+"markup_help_wiki" = "Link to another community page";
+"markup_help_wiki_label" = "Link to a page with custom label";
+"markup_help_table" = "Table";
+"markup_help_table_align" = "Table with column alignment (:--- left, :---: center, ---: right)";
+
"topic_changes_saved_comment" = "The updated title and settings will appear on the topic page.";
"failed_to_create_topic" = "Failed to create topic";
diff --git a/locales/ru.strings b/locales/ru.strings
index 5682b6954..1e58730df 100644
--- a/locales/ru.strings
+++ b/locales/ru.strings
@@ -1445,6 +1445,97 @@
"everyone_can_upload_audios" = "Все могут загружать аудиозаписи";
"display_list_of_topics_above_wall" = "Отображать список тем над стеной";
"topic_changes_saved_comment" = "Обновлённый заголовок и настройки появятся на странице с темой.";
+
+"materials" = "Материалы";
+"materials_disabled" = "Выключены";
+"materials_open" = "Открытые";
+"materials_limited" = "Ограниченные";
+"materials_hint" = "При включении раздела Материалы на главной странице сообщества появится блок со страницами. Если материалы «открытые», создавать страницы могут все участники. Если «ограниченные» — только руководители.";
+"error_invalid_pages_value" = "Некорректное значение для раздела материалов.";
+"group_pages" = "Страницы";
+"group_pages_main" = "Главная страница группы";
+"all_pages" = "все страницы";
+"group_pages_descr" = "Ваша группа может содержать дополнительные страницы с информацией, структуру и содержание которых можно настроить на этой вкладке.";
+"edit_group_pages" = "Редактировать страницы группы";
+"create_first_page" = "Создать первую страницу";
+"create_new_page" = "Создать новую страницу";
+"page_new_tab" = "Новая страница";
+"pages_only_one" = "Единственная страница";
+"pages_none" = "Нет страниц";
+"note_keep_revisions" = "Сохранять историю правок";
+"pages_empty" = "Страниц пока нет";
+"pages_count_zero" = "Нет страниц";
+"pages_count_one" = "$1 страница";
+"pages_count_few" = "$1 страницы";
+"pages_count_many" = "$1 страниц";
+"pages_count_other" = "$1 страниц";
+"page_is_main" = "(главная)";
+"page_created" = "Создана";
+"page_updated" = "Обновлена";
+"page_make_main" = "Сделать главной";
+"page_set_main_succ" = "Страница назначена главной.";
+"page_deleted" = "Страница удалена";
+"page_deleted_descr" = "Страница была успешно удалена.";
+"page_delete_confirm" = "Удалить эту страницу?";
+"page_no_title" = "Укажите название страницы.";
+"page_title_too_long" = "Слишком длинное название страницы.";
+"page_title_exists" = "Страница с таким названием уже существует.";
+"page_title_placeholder" = "Название страницы";
+"page_source_placeholder" = "Текст страницы (Markdown)…";
+"page_tab_view" = "Просмотр";
+"page_tab_edit" = "Редактирование";
+"page_tab_history" = "История";
+"page_preview" = "Предпросмотр";
+"save_page" = "Сохранить страницу";
+"page_access" = "Доступ к странице";
+"page_access_title" = "Доступ к этой странице";
+"page_who_can_view" = "Кто может просматривать эту страницу?";
+"page_who_can_edit" = "Кто может редактировать эту страницу?";
+"page_who_can_comment" = "Кто может комментировать эту страницу?";
+"page_access_everyone" = "Все пользователи";
+"page_access_members" = "Только участники сообщества";
+"page_access_admins" = "Только руководители группы";
+"page_access_friends" = "Только друзья";
+"page_access_owner" = "Только автор";
+"note_format" = "Формат";
+"note_format_markdown" = "Markdown";
+"note_format_html" = "HTML";
+"page_access_saved" = "Настройки доступа сохранены.";
+"page_access_denied" = "Недостаточно прав для просмотра этой страницы.";
+"page_no_revisions" = "История правок пуста.";
+"page_history_col_title" = "Название";
+"page_history_col_bytes" = "Размер";
+"page_history_col_time" = "Изменена";
+"page_history_col_author" = "Автор";
+"page_history_bytes_zero" = "$1 байт";
+"page_history_bytes_one" = "$1 байт";
+"page_history_bytes_few" = "$1 байта";
+"page_history_bytes_many" = "$1 байт";
+"page_history_bytes_other" = "$1 байт";
+"page_revision" = "Ревизия";
+"page_restore" = "Восстановить эту версию";
+"page_restore_confirm" = "Восстановить эту версию страницы?";
+"page_restored" = "Страница восстановлена из выбранной ревизии.";
+"back_to_pages" = "К списку страниц";
+"to_community" = "К сообществу";
+"markup_help" = "Помощь по разметке";
+"markup_help_intro" = "Страницы сообществ используют Markdown. Ниже — основные конструкции:";
+"markup_help_col_syntax" = "Разметка";
+"markup_help_col_result" = "Результат";
+"markup_help_col_descr" = "Описание";
+"markup_help_bold" = "Жирный текст";
+"markup_help_italic" = "Курсив";
+"markup_help_h1" = "Заголовок 1 уровня";
+"markup_help_h2" = "Заголовок 2 уровня";
+"markup_help_h3" = "Заголовок 3 уровня";
+"markup_help_list" = "Маркированный список";
+"markup_help_quote" = "Цитата";
+"markup_help_link" = "Ссылка";
+"markup_help_image" = "Изображение";
+"markup_help_wiki" = "Ссылка на другую страницу сообщества";
+"markup_help_wiki_label" = "Ссылка на страницу с другим текстом";
+"markup_help_table" = "Таблица";
+"markup_help_table_align" = "Таблица с выравниванием колонок (:--- слева, :---: по центру, ---: справа)";
"failed_to_create_topic" = "Не удалось создать тему";
"failed_to_change_topic" = "Не удалось изменить тему";
"no_title_specified" = "Заголовок не указан.";
diff --git a/tests/e2e/screenshots/groups.spec.ts-snapshots/club1-edit-linux.png b/tests/e2e/screenshots/groups.spec.ts-snapshots/club1-edit-linux.png
index 5b318cc8d..c4cbbd6f1 100644
Binary files a/tests/e2e/screenshots/groups.spec.ts-snapshots/club1-edit-linux.png and b/tests/e2e/screenshots/groups.spec.ts-snapshots/club1-edit-linux.png differ
diff --git a/tests/e2e/screenshots/groups.spec.ts-snapshots/club1-linux.png b/tests/e2e/screenshots/groups.spec.ts-snapshots/club1-linux.png
index 5ac761d04..09bf6acce 100644
Binary files a/tests/e2e/screenshots/groups.spec.ts-snapshots/club1-linux.png and b/tests/e2e/screenshots/groups.spec.ts-snapshots/club1-linux.png differ
diff --git a/tests/e2e/screenshots/notes.spec.ts-snapshots/note-create-linux.png b/tests/e2e/screenshots/notes.spec.ts-snapshots/note-create-linux.png
index 3bac682b9..aefa2e247 100644
Binary files a/tests/e2e/screenshots/notes.spec.ts-snapshots/note-create-linux.png and b/tests/e2e/screenshots/notes.spec.ts-snapshots/note-create-linux.png differ
diff --git a/tests/e2e/screenshots/notes.spec.ts-snapshots/note2-1-edit-linux.png b/tests/e2e/screenshots/notes.spec.ts-snapshots/note2-1-edit-linux.png
index 4efbf1ace..84332db13 100644
Binary files a/tests/e2e/screenshots/notes.spec.ts-snapshots/note2-1-edit-linux.png and b/tests/e2e/screenshots/notes.spec.ts-snapshots/note2-1-edit-linux.png differ
diff --git a/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-edit-linux.png b/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-edit-linux.png
new file mode 100644
index 000000000..4c7c660ff
Binary files /dev/null and b/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-edit-linux.png differ
diff --git a/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-history-linux.png b/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-history-linux.png
new file mode 100644
index 000000000..bc48b1cff
Binary files /dev/null and b/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-history-linux.png differ
diff --git a/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-linux.png b/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-linux.png
new file mode 100644
index 000000000..e9b9aa09c
Binary files /dev/null and b/tests/e2e/screenshots/pages.spec.ts-snapshots/page-1-1-linux.png differ
diff --git a/tests/e2e/screenshots/pages.spec.ts-snapshots/page-create-linux.png b/tests/e2e/screenshots/pages.spec.ts-snapshots/page-create-linux.png
new file mode 100644
index 000000000..e91296fb1
Binary files /dev/null and b/tests/e2e/screenshots/pages.spec.ts-snapshots/page-create-linux.png differ
diff --git a/tests/e2e/screenshots/pages.spec.ts-snapshots/pages-help-linux.png b/tests/e2e/screenshots/pages.spec.ts-snapshots/pages-help-linux.png
new file mode 100644
index 000000000..4879b5308
Binary files /dev/null and b/tests/e2e/screenshots/pages.spec.ts-snapshots/pages-help-linux.png differ
diff --git a/tests/e2e/screenshots/pages.spec.ts-snapshots/pages-list-linux.png b/tests/e2e/screenshots/pages.spec.ts-snapshots/pages-list-linux.png
new file mode 100644
index 000000000..1d52af189
Binary files /dev/null and b/tests/e2e/screenshots/pages.spec.ts-snapshots/pages-list-linux.png differ
diff --git a/tests/e2e/specs/notes.spec.ts b/tests/e2e/specs/notes.spec.ts
index b49bb94d7..fe8e391b9 100644
--- a/tests/e2e/specs/notes.spec.ts
+++ b/tests/e2e/specs/notes.spec.ts
@@ -18,10 +18,7 @@ test.describe('Notes', () => {
test('shows create note form', async ({ page }) => {
await page.goto('/notes/create');
- await expect(page.locator('.page_body')).toHaveScreenshot('note-create.png', {
- maxDiffPixels: 200,
- mask: [page.locator('.monaco-editor .scrollbar')],
- });
+ await expect(page.locator('.page_body')).toHaveScreenshot('note-create.png', { maxDiffPixels: 200 });
});
test('shows edit note page', async ({ page }) => {
diff --git a/tests/e2e/specs/pages.spec.ts b/tests/e2e/specs/pages.spec.ts
new file mode 100644
index 000000000..0812e9703
--- /dev/null
+++ b/tests/e2e/specs/pages.spec.ts
@@ -0,0 +1,66 @@
+import { test, expect } from '../fixtures.js';
+import { loginAsAlice, loginAsCharlie } from '../helpers.js';
+
+test.describe('Group wiki notes', () => {
+ test.beforeEach(async ({ page }) => {
+ await loginAsAlice(page);
+ });
+
+ test('shows pages list', async ({ page }) => {
+ await page.goto('/notes-1');
+ await expect(page.locator('.page_body')).toHaveScreenshot('pages-list.png', { maxDiffPixels: 200 });
+ });
+
+ test('shows page view', async ({ page }) => {
+ await page.goto('/note-1_1');
+ await expect(page.locator('.page_body')).toHaveScreenshot('page-1_1.png', { maxDiffPixels: 200 });
+ });
+
+ test('shows page edit form', async ({ page }) => {
+ await page.goto('/note-1_1/edit');
+ await expect(page.locator('.page_body')).toHaveScreenshot('page-1_1-edit.png', { maxDiffPixels: 200 });
+ });
+
+ test('shows create page form', async ({ page }) => {
+ await page.goto('/notes-1/create');
+ await expect(page.locator('.page_body')).toHaveScreenshot('page-create.png', { maxDiffPixels: 200 });
+ });
+
+ test('shows page history', async ({ page }) => {
+ await page.goto('/note-1_1/history');
+ await expect(page.locator('.page_body')).toHaveScreenshot('page-1_1-history.png', { maxDiffPixels: 200 });
+ });
+
+ test('shows markup help', async ({ page }) => {
+ await page.goto('/notes-1/help');
+ await expect(page.locator('.page_body')).toHaveScreenshot('pages-help.png', { maxDiffPixels: 200 });
+ });
+
+ test('creates a new page', async ({ page }) => {
+ await page.goto('/notes-1/create');
+ await page.fill('input[name="title"]', 'Rules');
+ await page.fill('textarea[name="source"]', '## Community rules\n\nBe nice.');
+ await page.click('input[type="submit"]');
+ await page.waitForURL(/\/note-1_\d+/);
+ await expect(page.locator('.wiki_page_title')).toHaveText('Rules');
+ await expect(page.locator('.wiki_page_body')).toContainText('Be nice');
+ });
+
+ test('shows edit pages link on club page', async ({ page }) => {
+ await page.goto('/club1');
+ await expect(page.locator('#profile_links a', { hasText: /Edit community pages|Редактировать страницы/ })).toBeVisible();
+ await expect(page.locator('.wiki_page_body, .content_title_expanded').filter({ hasText: /Welcome Page|Community main page|Главная/ }).first()).toBeVisible();
+ });
+});
+
+test.describe('Group wiki notes permissions', () => {
+ test('visitor can view open page but not edit', async ({ page }) => {
+ await loginAsCharlie(page);
+ await page.goto('/note-1_1');
+ await expect(page.locator('.wiki_page_title')).toHaveText('Welcome Page');
+ await expect(page.locator('.pages_editor_tab', { hasText: /Edit|Редактирование/ })).toHaveCount(0);
+
+ await page.goto('/note-1_1/edit');
+ await expect(page.locator('.page_body')).toContainText(/access|прав|forbidden|denied/i);
+ });
+});
diff --git a/tests/seed-data.sql b/tests/seed-data.sql
index 935b42c52..317eef1e4 100644
--- a/tests/seed-data.sql
+++ b/tests/seed-data.sql
@@ -178,6 +178,16 @@ INSERT INTO group_coadmins (user, club, comment, id) VALUES
(2, 1, 'Owner', 1),
(3, 1, 'Moderator', 2);
+-- Enable Materials (wiki notes) for Test Group and seed a main club note
+UPDATE `groups` SET pages = 1 WHERE id = 1;
+
+INSERT INTO notes (id, owner, created_by, virtual_id, created, edited, name, source, cached_content, format, deleted, is_main, view_access, edit_access, keep_revisions, anonymous) VALUES
+(2, -1, 2, 1, @ts_base - 2 * @ts_day, @ts_base - 1 * @ts_hour, 'Welcome Page', '# Hello group\n\nWelcome to our community wiki page!\n\n- Rule one\n- Rule two\n\nSee also [[Rules]].', NULL, 1, 0, 1, 0, 2, 1, 0);
+
+INSERT INTO note_revisions (id, note, editor, title, source, created) VALUES
+(1, 2, 2, 'Welcome Page', '# Hello group\n\nWelcome to our community wiki page!', @ts_base - 2 * @ts_day),
+(2, 2, 2, 'Welcome Page', '# Hello group\n\nWelcome to our community wiki page!\n\n- Rule one\n- Rule two\n\nSee also [[Rules]].', @ts_base - 1 * @ts_hour);
+
-- Group wall posts (Bob posts to group, Alice posts as owner)
INSERT INTO posts (id, owner, wall, virtual_id, created, edited, content, flags, nsfw, ad, deleted, suggested) VALUES
(12, 3, 1, 1, @ts_base - 10 * @ts_hour, NULL, 'Hello group! Bob here, just joined.', NULL, 0, 0, 0, 0),
@@ -252,7 +262,11 @@ INSERT INTO ovk_upgrade_history (`level`, `timestamp`, `operator`) VALUES
(57, @ts_base - 365 * @ts_day, 'test-seed'),
(58, @ts_base - 365 * @ts_day, 'test-seed'),
(59, @ts_base - 365 * @ts_day, 'test-seed'),
-(60, @ts_base - 365 * @ts_day, 'test-seed');
+(60, @ts_base - 365 * @ts_day, 'test-seed'),
+(61, @ts_base - 365 * @ts_day, 'test-seed'),
+(62, @ts_base - 365 * @ts_day, 'test-seed'),
+(63, @ts_base - 365 * @ts_day, 'test-seed'),
+(65, @ts_base - 365 * @ts_day, 'test-seed');
-- Photos for Alice (profile id = 2, album id = 1)
INSERT INTO photos (id, owner, virtual_id, created, edited, hash, deleted, description) VALUES
@@ -268,8 +282,8 @@ INSERT INTO videos (id, owner, virtual_id, created, edited, hash, link, deleted,
(1, 2, 1, @ts_base - 5 * @ts_day, NULL, '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', NULL, 0, 'My first video', 'Welcome Video', 120, 720, 1280);
-- Note for Alice
-INSERT INTO notes (id, owner, virtual_id, created, edited, name, source, cached_content, deleted) VALUES
-(1, 2, 1, @ts_base - 3 * @ts_day, NULL, 'My First Note', 'This is the content of my first note on OpenVK. Writing notes is fun!', NULL, 0);
+INSERT INTO notes (id, owner, created_by, virtual_id, created, edited, name, source, cached_content, format, deleted, is_main, view_access, edit_access, keep_revisions, anonymous) VALUES
+(1, 2, NULL, 1, @ts_base - 3 * @ts_day, NULL, 'My First Note', 'This is the content of my first note on OpenVK. Writing notes is fun!', NULL, 0, 0, 0, 0, 2, 0, 0);
-- Topics in Test Group (group id = 1), owned by Bob (profile id = 3)
INSERT INTO topics (id, `group`, owner, virtual_id, created, edited, title, closed, pinned, anonymous, flags, deleted) VALUES
@@ -408,6 +422,7 @@ ALTER TABLE likes AUTO_INCREMENT = 100;
ALTER TABLE photos AUTO_INCREMENT = 100;
ALTER TABLE videos AUTO_INCREMENT = 100;
ALTER TABLE notes AUTO_INCREMENT = 100;
+ALTER TABLE note_revisions AUTO_INCREMENT = 100;
ALTER TABLE topics AUTO_INCREMENT = 100;
ALTER TABLE audios AUTO_INCREMENT = 100;
ALTER TABLE playlists AUTO_INCREMENT = 100;