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)} + +
+
+
+ {$mainPage->getTitle()} +
+ {$mainPage->getText($thisUser ?? null)|noescape} +
+
+
+ {_page_access_denied} +
+
+
+ {_create_first_page} + {_pages_empty} +
+
+ {tr("pages_count", $pagesCount)} +
+
+
+
{var $followersCount = $club->getFollowersCount()} @@ -224,6 +257,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\n\n\n\n\n
HeaderHeader
", + "Cell
\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("![|](url)"); + 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("![|](/photo" + id + ")"); + 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;