code:
package routes

import (
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
	"github.com/stretchr/testify/require"
)

func TestSwaggerRoutes(t *testing.T) {
	logger := logrus.New()

	g := gin.Default()

	router := NewSwaggerAPIRoutes(logger)
	router.Setup(g.Group("/swagger"))

	t.Run("/swagger/doc.json", func(t *testing.T) {
		w := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/swagger/doc.json", nil)
		g.ServeHTTP(w, req)
		require.Equal(t, 200, w.Code)
	})

	t.Run("/swagger/ redirects", func(t *testing.T) {
		w := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/swagger/", nil)
		g.ServeHTTP(w, req)
		require.Equal(t, 302, w.Code)
		require.Equal(t, "/swagger/index.html", w.Header().Get("Location"))
	})

	t.Run("/swagger redirects", func(t *testing.T) {
		w := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/swagger/", nil)
		g.ServeHTTP(w, req)
		require.Equal(t, 302, w.Code)
		require.Equal(t, "/swagger/index.html", w.Header().Get("Location"))
	})
}


code:
package routes

import (
	"github.com/gin-gonic/gin"
	"github.com/go-shiori/shiori/internal/http/response"
	"github.com/go-shiori/shiori/internal/model"
	"github.com/sirupsen/logrus"
)

type SystemRoutes struct {
	logger *logrus.Logger
}

func (r *SystemRoutes) Setup(group *gin.RouterGroup) model.Routes {
	group.GET("/liveness", r.livenessHandler)
	return r
}

func (r *SystemRoutes) livenessHandler(c *gin.Context) {
	response.Send(c, 200, struct {
		Version string `json:"version"`
		Commit  string `json:"commit"`
		Date    string `json:"date"`
	}{
		Version: model.BuildVersion,
		Commit:  model.BuildCommit,
		Date:    model.BuildDate,
	})
}

func NewSystemRoutes(logger *logrus.Logger) *SystemRoutes {
	return &SystemRoutes{
		logger: logger,
	}
}

repo_name: orochi/shiori

license: mit


code:
package routes

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
	"github.com/stretchr/testify/require"
)

func TestSystemRoutes(t *testing.T) {
	logger := logrus.New()

	g := gin.Default()
	router := NewSystemRoutes(logger)
	router.Setup(g.Group("/system"))

	t.Run("/system/liveness", func(t *testing.T) {
		w := httptest.NewRecorder()
		req, _ := http.NewRequest("GET", "/system/liveness", nil)
		g.ServeHTTP(w, req)
		require.Equal(t, 200, w.Code)
	})

}


code:
package http

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"

	"github.com/gin-contrib/requestid"
	"github.com/gin-gonic/gin"
	"github.com/go-shiori/shiori/internal/config"
	"github.com/go-shiori/shiori/internal/http/middleware"
	"github.com/go-shiori/shiori/internal/http/routes"
	api_v1 "github.com/go-shiori/shiori/internal/http/routes/api/v1"
	"github.com/go-shiori/shiori/internal/model"
	"github.com/sirupsen/logrus"
	ginlogrus "github.com/toorop/gin-logrus"
)

type HttpServer struct {
	engine *gin.Engine
	http   *http.Server
	logger *logrus.Logger
}

func (s *HttpServer) Setup(cfg *config.Config, deps *config.Dependencies) *HttpServer {
	if !cfg.Development {
		gin.SetMode(gin.ReleaseMode)
	}

	s.engine = gin.New()

	s.engine.Use(requestid.New())

	if cfg.Http.AccessLog {
		s.engine.Use(ginlogrus.Logger(deps.Log))
	}

	s.engine.Use(
		middleware.AuthMiddleware(deps),
		gin.Recovery(),
	)

	if cfg.Http.ServeWebUI {
		routes.NewFrontendRoutes(s.logger, cfg).Setup(s.engine)
	}

	// LegacyRoutes will be here until we migrate everything from internal/webserver to this new
	// package.
	legacyRoutes := routes.NewLegacyAPIRoutes(s.logger, deps, cfg)
	legacyRoutes.Setup(s.engine)

	s.handle("/system", routes.NewSystemRoutes(s.logger))
	s.handle("/bookmark", routes.NewBookmarkRoutes(s.logger, deps))
	s.handle("/api/v1", api_v1.NewAPIRoutes(s.logger, deps, legacyRoutes.HandleLogin))
	s.handle("/swagger", routes.NewSwaggerAPIRoutes(s.logger))

	s.http.Handler = s.engine
	s.http.Addr = fmt.Sprintf("%s%d", cfg.Http.Address, cfg.Http.Port)

	return s
}

func (s *HttpServer) handle(path string, routes model.Routes) {
	group := s.engine.Group(path)
	routes.Setup(group)
}

func (s *HttpServer) Start(_ context.Context) error {
	s.logger.WithField("addr", s.http.Addr).Info("starting http server")
	go func() {
		if err := s.http.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			s.logger.Fatalf("listen and serve error: %s\n", err)
		}
	}()
	return nil
}

func (s *HttpServer) Stop(ctx context.Context) error {
	s.logger.WithField("addr", s.http.Addr).Info("stopping http server")
	return s.http.Shutdown(ctx)
}

func (s *HttpServer) WaitStop(ctx context.Context) {
	signals := make(chan os.Signal, 1)
	signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)

	sig := <-signals
	s.logger.WithField("signal", sig.String()).Info("signal received, shutting down")

	if err := s.Stop(ctx); err != nil {
		s.logger.WithError(err).Error("error stopping server")
	}
}

func NewHttpServer(logger *logrus.Logger) *HttpServer {
	return &HttpServer{
		logger: logger,
		http:   &http.Server{},
	}
}


code:
package model

import (
	"database/sql/driver"
	"encoding/json"
	"fmt"
)

// Account is the database model for account.
type Account struct {
	ID       int        `db:"id"       json:"id"`
	Username string     `db:"username" json:"username"`
	Password string     `db:"password" json:"password,omitempty"`
	Owner    bool       `db:"owner"    json:"owner"`
	Config   UserConfig `db:"config"               json:"config"`
}

type UserConfig struct {
	ShowId        bool `json:"ShowId"`
	ListMode      bool `json:"ListMode"`
	HideThumbnail bool `json:"HideThumbnail"`
	HideExcerpt   bool `json:"HideExcerpt"`
	NightMode     bool `json:"NightMode"`
	KeepMetadata  bool `json:"KeepMetadata"`
	UseArchive    bool `json:"UseArchive"`
	CreateEbook   bool `json:"CreateEbook"`
	MakePublic    bool `json:"MakePublic"`
}

// ToDTO converts Account to AccountDTO.
func (a Account) ToDTO() AccountDTO {
	return AccountDTO{
		ID:       a.ID,
		Username: a.Username,
		Owner:    a.Owner,
		Config:   a.Config,
	}
}

// AccountDTO is data transfer object for Account.
type AccountDTO struct {
	ID       int        `json:"id"`
	Username string     `json:"username"`
	Owner    bool       `json:"owner"`
	Config   UserConfig `json:"config"`
}

func (c *UserConfig) Scan(value interface{}) error {
	switch v := value.(type) {
	case []byte:
		json.Unmarshal(v, &c)
		return nil
	case string:
		json.Unmarshal([]byte(v), &c)
		return nil
	default:
		return fmt.Errorf("unsupported type: %T", v)
	}
}

func (c UserConfig) Value() (driver.Value, error) {
	return json.Marshal(c)
}


code:
package model

// DataDirPerm the default filesystem permissions for the data directory/archives
const DataDirPerm = 0744

// DatabaseDateFormat the string formatting of datetimes for the database
const DatabaseDateFormat = "2006-01-02 15:04:05"


code:
package model

import "github.com/gin-gonic/gin"

const (
	// ContextAccountKey is the key used to store the account model in the gin context.
	ContextAccountKey = "account"

	// AuthorizationHeader is the name of the header used to send the token.
	AuthorizationHeader = "Authorization"
	// AuthorizationTokenType is the type of token used in the Authorization header.
	AuthorizationTokenType = "Bearer"
)

type Routes interface {
	Setup(group *gin.RouterGroup) Routes
}


code:
package model

import "time"

type LegacyLoginHandler func(account Account, expTime time.Duration) (string, error)


code:
package model

// Variables set my the main package coming from ldflags
var (
	BuildVersion = "dev"
	BuildCommit  = "none"
	BuildDate    = "unknown"
)


code:
package model

// Tag is the tag for a bookmark.
type Tag struct {
	ID         int    `db:"id"          json:"id"`
	Name       string `db:"name"        json:"name"`
	NBookmarks int    `db:"n_bookmarks" json:"nBookmarks,omitempty"`
	Deleted    bool   `json:"-"`
}

// Bookmark is the record for an URL.
type Bookmark struct {
	ID            int    `db:"id"            json:"id"`
	URL           string `db:"url"           json:"url"`
	Title         string `db:"title"         json:"title"`
	Excerpt       string `db:"excerpt"       json:"excerpt"`
	Author        string `db:"author"        json:"author"`
	Public        int    `db:"public"        json:"public"`
	Modified      string `db:"modified"      json:"modified"`
	Content       string `db:"content"       json:"-"`
	HTML          string `db:"html"          json:"html,omitempty"`
	ImageURL      string `db:"image_url"     json:"imageURL"`
	HasContent    bool   `db:"has_content"   json:"hasContent"`
	HasArchive    bool   `json:"hasArchive"`
	HasEbook      bool   `json:"hasEbook"`
	Tags          []Tag  `json:"tags"`
	CreateArchive bool   `json:"create_archive"`
	CreateEbook   bool   `json:"create_ebook"`
}


code:
package testutil

import (
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/gin-gonic/gin"
)

// NewGin returns a new gin engine with test mode enabled.
func NewGin() *gin.Engine {
	engine := gin.New()
	gin.SetMode(gin.TestMode)
	return engine
}

type Option = func(*http.Request)

func WithBody(body string) Option {
	return func(request *http.Request) {
		request.Body = io.NopCloser(strings.NewReader(body))
	}
}

func WithHeader(name, value string) Option {
	return func(request *http.Request) {
		request.Header.Add(name, value)
	}
}

func PerformRequest(handler http.Handler, method, path string, options ...Option) *httptest.ResponseRecorder {
	recorder := httptest.NewRecorder()
	return PerformRequestWithRecorder(recorder, handler, method, path, options...)
}

func PerformRequestWithRecorder(recorder *httptest.ResponseRecorder, r http.Handler, method, path string, options ...Option) *httptest.ResponseRecorder {
	request, err := http.NewRequest(method, path, nil)
	if err != nil {
		panic(err)
	}
	for _, opt := range options {
		opt(request)
	}
	r.ServeHTTP(recorder, request)
	return recorder
}


code:
package testutil

import (
	"encoding/json"
	"io"
	"testing"

	"github.com/go-shiori/shiori/internal/http/response"
	"github.com/pkg/errors"
	"github.com/stretchr/testify/require"
)

type testResponse struct {
	Response *response.Response
}

func (r *testResponse) AssertMessageIsEmptyList(t *testing.T) {
	require.Equal(t, []interface{}{}, r.Response.Message)
}

func (r *testResponse) AssertNilMessage(t *testing.T) {
	require.Equal(t, nil, r.Response.Message)
}

func (r testResponse) AssertMessageEquals(t *testing.T, expected interface{}) {
	require.Equal(t, expected, r.Response.Message)
}

func (r *testResponse) AssertOk(t *testing.T) {
	require.True(t, r.Response.Ok)
}

func (r *testResponse) AssertNotOk(t *testing.T) {
	require.False(t, r.Response.Ok)
}

func NewTestResponseFromBytes(b []byte) (*testResponse, error) {
	r := testResponse{}
	if err := json.Unmarshal(b, &r.Response); err != nil {
		return nil, errors.Wrap(err, "error parsing response")
	}
	return &r, nil
}

func NewTestResponseFromReader(r io.Reader) (*testResponse, error) {
	response := testResponse{}
	decoder := json.NewDecoder(r)
	if err := decoder.Decode(&response.Response); err != nil {
		return nil, errors.Wrap(err, "error parsing response")
	}
	return &response, nil
}


code:
package testutil

import (
	"context"
	"os"
	"testing"

	"github.com/go-shiori/shiori/internal/config"
	"github.com/go-shiori/shiori/internal/database"
	"github.com/go-shiori/shiori/internal/domains"
	"github.com/sirupsen/logrus"
	"github.com/stretchr/testify/require"
)

func GetTestConfigurationAndDependencies(t *testing.T, ctx context.Context, logger *logrus.Logger) (*config.Config, *config.Dependencies) {
	tmp, err := os.CreateTemp("", "")
	require.NoError(t, err)

	cfg := config.ParseServerConfiguration(ctx, logger)
	cfg.Http.SecretKey = "test"

	tempDir, err := os.MkdirTemp("", "")
	require.NoError(t, err)

	db, err := database.OpenSQLiteDatabase(ctx, tmp.Name())
	require.NoError(t, err)
	require.NoError(t, db.Migrate())

	cfg.Storage.DataDir = tempDir

	deps := config.NewDependencies(logger, db, cfg)
	deps.Database = db
	deps.Domains.Auth = domains.NewAccountsDomain(logger, cfg.Http.SecretKey, db)
	deps.Domains.Archiver = domains.NewArchiverDomain(logger, cfg.Storage.DataDir)

	return cfg, deps
}


code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="assets/css/style.css" rel="stylesheet">
    <title>Shiori</title>
</head>
<body>
    Not found
</body>
</html>


code:
@font-face {
    font-family: 'Source Sans Pro';
    font-style: normal;
    font-weight: 200;
    src: local('Source Sans Pro ExtraLight'), local('SourceSansPro-ExtraLight'), url(fonts/source-sans-pro-v13-latin-200.woff2) format('woff2'), url(fonts/source-sans-pro-v13-latin-200.woff) format('woff')
}

@font-face {
    font-family: 'Source Sans Pro';
    font-style: normal;
    font-weight: 400;
    src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(fonts/source-sans-pro-v13-latin-regular.woff2) format('woff2'), url(fonts/source-sans-pro-v13-latin-regular.woff) format('woff')
}

@font-face {
    font-family: 'Source Sans Pro';
    font-style: normal;
    font-weight: 600;
    src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(fonts/source-sans-pro-v13-latin-600.woff2) format('woff2'), url(fonts/source-sans-pro-v13-latin-600.woff) format('woff')
}

@font-face {
    font-family: 'Source Sans Pro';
    font-style: normal;
    font-weight: 700;
    src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(fonts/source-sans-pro-v13-latin-700.woff2) format('woff2'), url(fonts/source-sans-pro-v13-latin-700.woff) format('woff')
}


code:
var template = `

{{title}}

{{excerpt}}

{{id}}

 0">
		{{tag.name}}

			{{hostnameURL}}

		<template v-if="!editMode && menuVisible">

		</template>

`;

export default {
	template: template,
	props: {
		id: Number,
		url: String,
		title: String,
		excerpt: String,
		public: Number,
		imageURL: String,
		hasContent: Boolean,
		hasArchive: Boolean,
		hasEbook: Boolean,
		index: Number,
		ShowId: Boolean,
		editMode: Boolean,
		ListMode: Boolean,
		HideThumbnail: Boolean,
		HideExcerpt: Boolean,
		selected: Boolean,
		menuVisible: Boolean,
		tags: {
			type: Array,
			default() {
				return [];
			},
		},
	},
	computed: {
		mainURL() {
			if (this.hasContent) {
				return new URL(`bookmark/${this.id}/content`, document.baseURI);
			} else if (this.hasArchive) {
				return new URL(`bookmark/${this.id}/archive`, document.baseURI);
			} else {
				return this.url;
			}
		},
		ebookURL() {
			if (this.hasEbook) {
				return new URL(`bookmark/${this.id}/ebook`, document.baseURI);
			} else {
				return null;
			}
		},
		hostnameURL() {
			var url = new URL(this.url);
			return url.hostname.replace(/^www\./, "");
		},
		thumbnailVisible() {
			return this.imageURL !== "" && !this.HideThumbnail;
		},
		excerptVisible() {
			return this.excerpt !== "" && !this.thumbnailVisible && !this.HideExcerpt;
		},
		thumbnailStyleURL() {
			return {
				backgroundImage: `url("${this.imageURL}")`,
			};
		},
		eventItem() {
			return {
				id: this.id,
				index: this.index,
			};
		},
	},
	methods: {
		tagClicked(name, event) {
			this.$emit("tag-clicked", name, event);
		},
		selectBookmark() {
			this.$emit("select", this.eventItem);
		},
		editBookmark() {
			this.$emit("edit", this.eventItem);
		},
		deleteBookmark() {
			this.$emit("delete", this.eventItem);
		},
		updateBookmark() {
			this.$emit("update", this.eventItem);
		},
		downloadebook() {
			const id = this.id;
			const ebook_url = new URL(`bookmark/${id}/ebook`, document.baseURI);
			const downloadLink = document.createElement("a");
			downloadLink.href = ebook_url.toString();
			downloadLink.download = `${this.title}.epub`;
			downloadLink.click();
		},
	},
};


code:
var template = `

{{title}}

			<slot>

{{content}}

				<template v-for="(field,index) in formFields">
					{{field.label}} :
					<textarea v-if="field.type === 'area'"
						:style="{gridColumnEnd: showLabel ? null : 'span 2'}"
						:placeholder="field.label"
						:tabindex="index+1"
						ref="input"
						v-model="field.value"
						@focus="$event.target.select()"
						@keyup="handleInput(index)">
					</textarea>

						<input type="checkbox"
							v-model="field.value"
							:tabindex="index+1">{{field.label}}

					<input v-else
						:style="{gridColumnEnd: showLabel ? null : 'span 2'}"
						:type="fieldType(field)"
						:placeholder="field.label"
						:tabindex="index+1"
						ref="input"
						v-model="field.value"
						@focus="$event.target.select()"
						@keyup="handleInput(index)"
						@keyup.enter="handleInputEnter(index)">
					<button :ref="'suggestion-'+index"
						v-if="field.suggestion"
						@click="handleInputEnter(index)"
						class="suggestion">{{field.suggestion}}</button>
				</template>
			</slot>

			<slot v-else name="custom-footer">
				{{secondText}}

				{{mainText}}

			</slot>

`;

export default {
	template: template,
	props: {
		title: String,
		loading: Boolean,
		visible: Boolean,
		content: {
			type: String,
			default: "",
		},
		fields: {
			type: Array,
			default() {
				return [];
			},
		},
		showLabel: {
			type: Boolean,
			default: false,
		},
		mainText: {
			type: String,
			default: "OK",
		},
		secondText: String,
		mainClick: {
			type: Function,
			default() {
				this.visible = false;
			},
		},
		secondClick: {
			type: Function,
			default() {
				this.visible = false;
			},
		},
		escPressed: {
			type: Function,
			default() {
				this.visible = false;
			},
		},
	},
	data() {
		return {
			formFields: [],
		};
	},
	computed: {
		btnTabIndex() {
			return this.fields.length + 1;
		},
	},
	watch: {
		fields: {
			immediate: true,
			handler() {
				this.formFields = this.fields.map((field) => {
					if (typeof field === "string")
						return {
							name: field,
							label: field,
							value: "",
							type: "text",
							dictionary: [],
							separator: " ",
							suggestion: undefined,
						};

					if (typeof field === "object")
						return {
							name: field.name || "",
							label: field.label || "",
							value: field.value || "",
							type: field.type || "text",
							dictionary:
								field.dictionary instanceof Array ? field.dictionary : [],
							separator: field.separator || " ",
							suggestion: undefined,
						};
				});
			},
		},
		"fields.length"() {
			this.focus();
		},
		visible: {
			immediate: true,
			handler() {
				this.focus();
			},
		},
	},
	methods: {
		fieldType(f) {
			var type = f.type || "text";
			if (type !== "text" && type !== "password") return "text";
			else return type;
		},
		handleMainClick() {
			var data = {};
			this.formFields.forEach((field) => {
				var value = field.value;
				if (field.type === "number") value = parseInt(value, 10) || 0;
				else if (field.type === "float") value = parseFloat(value) || 0.0;
				else if (field.type === "check") value = Boolean(value);
				data[field.name] = value;
			});

			this.mainClick(data);
		},
		handleSecondClick() {
			this.secondClick();
		},
		handleEscPressed() {
			this.escPressed();
		},
		handleInput(index) {
			// Create initial variable
			var field = this.formFields[index],
				dictionary = field.dictionary;

			// Make sure dictionary is not empty
			if (dictionary.length === 0) return;

			// Fetch suggestion from dictionary
			var words = field.value.split(field.separator),
				lastWord = words[words.length - 1].toLowerCase(),
				suggestion;

			if (lastWord !== "") {
				suggestion = dictionary.find((word) => {
					return word.toLowerCase().startsWith(lastWord);
				});
			}

			this.formFields[index].suggestion = suggestion;

			// Make sure suggestion exist
			if (suggestion == null) return;

			// Display suggestion
			this.$nextTick(() => {
				var input = this.$refs.input[index],
					suggestionNode = this.$refs["suggestion-" + index][0],
					inputRect = input.getBoundingClientRect();

				suggestionNode.style.top = inputRect.bottom - 1 + "px";
				suggestionNode.style.left = inputRect.left + "px";
			});
		},
		handleInputEnter(index) {
			var suggestion = this.formFields[index].suggestion;

			if (suggestion == null) {
				this.handleMainClick();
				return;
			}

			var separator = this.formFields[index].separator,
				words = this.formFields[index].value.split(separator);

			words.pop();
			words.push(suggestion);

			this.formFields[index].value = words.join(separator) + separator;
			this.formFields[index].suggestion = undefined;
			// Focus input again after suggestion is accepted
			this.$refs.input[index].focus();
		},
		focus() {
			this.$nextTick(() => {
				if (!this.visible) return;

				var fields = this.$refs.input,
					otherInput = this.$el.querySelectorAll("input"),
					button = this.$refs.mainButton;

				if (fields && fields.length > 0) {
					this.$refs.input[0].focus();
					this.$refs.input[0].select();
				} else if (otherInput && otherInput.length > 0) {
					otherInput[0].focus();
					otherInput[0].select();
				} else if (button) {
					button.focus();
				}
			});
		},
	},
};


code:
var template = `

Page

	<input type="text"
		placeholder="1"
		:value="page"
		@focus="$event.target.select()"
		@keyup.enter="changePage($event.target.value)"
		:disabled="editMode">

{{maxPage}}

	<template v-if="!editMode">
		 2" title="Go to first page" @click="changePage(1)">

		 1" title="Go to previous page" @click="changePage(page-1)">

		<a v-if="page < maxPage" title="Go to next page" @click="changePage(page+1)">

		<a v-if="page < maxPage - 1" title="Go to last page" @click="changePage(maxPage)">

	</template>

`;

export default {
	template: template,
	props: {
		page: Number,
		maxPage: Number,
		editMode: Boolean,
	},
	methods: {
		changePage(page) {
			page = parseInt(page, 10) || 0;
			if (page >= this.maxPage) page = this.maxPage;
			else if (page <= 1) page = 1;

			this.$emit("change", page);
		},
	},
};


code:
export default {
	props: {
		activeAccount: {
			type: Object,
			default() {
				return {
					id: 0,
					username: "",
					owner: false,
				};
			},
		},
		appOptions: {
			type: Object,
			default() {
				return {
					ShowId: false,
					ListMode: false,
					NightMode: false,
					HideThumbnail: false,
					HideExcerpt: false,
					KeepMetadata: false,
					UseArchive: false,
					CreateEbook: false,
					MakePublic: false,
				};
			},
		},
	},
	data() {
		return {
			dialog: {},
		};
	},
	methods: {
		defaultDialog() {
			return {
				visible: false,
				loading: false,
				title: "",
				content: "",
				fields: [],
				showLabel: false,
				mainText: "Yes",
				secondText: "",
				mainClick: () => {
					this.dialog.visible = false;
				},
				secondClick: () => {
					this.dialog.visible = false;
				},
				escPressed: () => {
					if (!this.loading) this.dialog.visible = false;
				},
			};
		},
		showDialog(cfg) {
			var base = this.defaultDialog();
			base.visible = true;
			if (cfg.loading) base.loading = cfg.loading;
			if (cfg.title) base.title = cfg.title;
			if (cfg.content) base.content = cfg.content;
			if (cfg.fields) base.fields = cfg.fields;
			if (cfg.showLabel) base.showLabel = cfg.showLabel;
			if (cfg.mainText) base.mainText = cfg.mainText;
			if (cfg.secondText) base.secondText = cfg.secondText;
			if (cfg.mainClick) base.mainClick = cfg.mainClick;
			if (cfg.secondClick) base.secondClick = cfg.secondClick;
			if (cfg.escPressed) base.escPressed = cfg.escPressed;
			this.dialog = base;
		},
		async getErrorMessage(err) {
			switch (err.constructor) {
				case Error:
					return err.message;
				case Response:
					var text = await err.text();
					return `${text} (${err.status})`;
				default:
					return err;
			}
		},
		isSessionError(err) {
			switch (
				err
					.toString()
					.replace(/\(\d+\)/g, "")
					.trim()
					.toLowerCase()
			) {
				case "session is not exist":
				case "session has been expired":
					return true;
				default:
					return false;
			}
		},
		showErrorDialog(msg) {
			var sessionError = this.isSessionError(msg),
				dialogContent = sessionError
					? "Session has expired, please login again."
					: msg;

			this.showDialog({
				visible: true,
				title: "Error",
				content: dialogContent,
				mainText: "OK",
				mainClick: () => {
					this.dialog.visible = false;
					if (sessionError) {
						var loginUrl = new Url("login", document.baseURI);
						loginUrl.query.dst = window.location.href;

						document.cookie = `session-id=; Path=${
							new URL(document.baseURI).pathname
						}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;
						location.href = loginUrl.toString();
					}
				},
			});
		},
	},
};


code:
var template = `

        <input type="text" placeholder="Search url, keyword or tags" v-model.trim="search" @focus="$event.target.select()" @keyup.enter="searchBookmarks"/>

         0" title="Show tags" @click="showDialogTags">

{{selection.length}} items selected

No saved bookmarks yet :(

        <pagination-box v-if="maxPage > 1"
            :page="page"
            :maxPage="maxPage"
            :editMode="editMode"
            @change="changePage">
        </pagination-box>
        <bookmark-item v-for="(book, index) in bookmarks"
            :id="book.id"
            :url="book.url"
            :title="book.title"
            :excerpt="book.excerpt"
            :public="book.public"
            :imageURL="book.imageURL"
            :hasContent="book.hasContent"
            :hasArchive="book.hasArchive"
            :hasEbook="book.hasEbook"
            :tags="book.tags"
            :index="index"
            :key="book.id"
            :editMode="editMode"
            :ShowId="appOptions.ShowId"
            :ListMode="appOptions.ListMode"
            :HideThumbnail="appOptions.HideThumbnail"
            :HideExcerpt="appOptions.HideExcerpt"
            :selected="isSelected(book.id)"
            :menuVisible="activeAccount.owner"
            @select="toggleSelection"
            @tag-clicked="bookmarkTagClicked"
            @edit="showDialogEdit"
            @delete="showDialogDelete"
            @generate-ebook="ebookGenerate"
            @update="showDialogUpdateCache">
        </bookmark-item>
        <pagination-box v-if="maxPage > 1"
            :page="page"
            :maxPage="maxPage"
            :editMode="editMode"
            @change="changePage">
        </pagination-box>

    <custom-dialog id="dialog-tags" v-bind="dialogTags">
        (all tagged)
        (all untagged)

            #{{tag.name}}{{tag.nBookmarks}}

    </custom-dialog>
    <custom-dialog v-bind="dialog"/>

`;

import paginationBox from "../component/pagination.js";
import bookmarkItem from "../component/bookmark.js";
import customDialog from "../component/dialog.js";
import basePage from "./base.js";

export default {
	template: template,
	mixins: [basePage],
	components: {
		bookmarkItem,
		paginationBox,
		customDialog,
	},
	data() {
		return {
			loading: false,
			editMode: false,
			selection: [],

			search: "",
			page: 0,
			maxPage: 0,
			bookmarks: [],
			tags: [],

			dialogTags: {
				visible: false,
				editMode: false,
				title: "Existing Tags",
				mainText: "OK",
				secondText: "Rename Tags",
				mainClick: () => {
					if (this.dialogTags.editMode) {
						this.dialogTags.editMode = false;
					} else {
						this.dialogTags.visible = false;
					}
				},
				secondClick: () => {
					this.dialogTags.editMode = true;
				},
				escPressed: () => {
					this.dialogTags.visible = false;
					this.dialogTags.editMode = false;
				},
			},
		};
	},
	computed: {
		listIsEmpty() {
			return this.bookmarks.length <= 0;
		},
	},
	watch: {
		"dialogTags.editMode"(editMode) {
			if (editMode) {
				this.dialogTags.title = "Rename Tags";
				this.dialogTags.mainText = "Cancel";
				this.dialogTags.secondText = "";
			} else {
				this.dialogTags.title = "Existing Tags";
				this.dialogTags.mainText = "OK";
				this.dialogTags.secondText = "Rename Tags";
			}
		},
	},
	methods: {
		reloadData() {
			if (this.loading) return;
			this.page = 1;
			this.search = "";
			this.loadData(true, true);
		},
		loadData(saveState, fetchTags) {
			if (this.loading) return;

			// Set default args
			saveState = typeof saveState === "boolean" ? saveState : true;
			fetchTags = typeof fetchTags === "boolean" ? fetchTags : false;

			// Parse search query
			var keyword = this.search,
				rxExcludeTagA = /(^|\s)-tag:["']([^"']+)["']/i, // -tag:"with space"
				rxExcludeTagB = /(^|\s)-tag:(\S+)/i, // -tag:without-space
				rxIncludeTagA = /(^|\s)tag:["']([^"']+)["']/i, // tag:"with space"
				rxIncludeTagB = /(^|\s)tag:(\S+)/i, // tag:without-space
				tags = [],
				excludedTags = [],
				rxResult;

			// Get excluded tag first, while also removing it from keyword
			while ((rxResult = rxExcludeTagA.exec(keyword))) {
				keyword = keyword.replace(rxResult[0], "");
				excludedTags.push(rxResult[2]);
			}

			while ((rxResult = rxExcludeTagB.exec(keyword))) {
				keyword = keyword.replace(rxResult[0], "");
				excludedTags.push(rxResult[2]);
			}

			// Get included tags
			while ((rxResult = rxIncludeTagA.exec(keyword))) {
				keyword = keyword.replace(rxResult[0], "");
				tags.push(rxResult[2]);
			}

			while ((rxResult = rxIncludeTagB.exec(keyword))) {
				keyword = keyword.replace(rxResult[0], "");
				tags.push(rxResult[2]);
			}

			// Trim keyword
			keyword = keyword.trim().replace(/\s+/g, " ");

			// Prepare URL for API
			var url = new URL("api/bookmarks", document.baseURI);
			url.search = new URLSearchParams({
				keyword: keyword,
				tags: tags.join(","),
				exclude: excludedTags.join(","),
				page: this.page,
			});

			// Fetch data from API
			var skipFetchTags = Error("skip fetching tags");

			this.loading = true;
			fetch(url, {
				headers: {
					"Content-Type": "application/json",
					Authorization: "Bearer " + localStorage.getItem("shiori-token"),
				},
			})
				.then((response) => {
					if (!response.ok) throw response;
					return response.json();
				})
				.then((json) => {
					// Set data
					this.page = json.page;
					this.maxPage = json.maxPage;
					this.bookmarks = json.bookmarks;

					// Save state and change URL if needed
					if (saveState) {
						var history = {
							activePage: "page-home",
							search: this.search,
							page: this.page,
						};

						var url = new Url(document.baseURI);
						url.hash = "home";
						url.clearQuery();
						if (this.page > 1) url.query.page = this.page;
						if (this.search !== "") url.query.search = this.search;

						window.history.pushState(history, "page-home", url);
					}

					// Fetch tags if requested
					if (fetchTags) {
						return fetch(new URL("api/tags", document.baseURI), {
							headers: {
								"Content-Type": "application/json",
								Authorization: "Bearer " + localStorage.getItem("shiori-token"),
							},
						});
					} else {
						this.loading = false;
						throw skipFetchTags;
					}
				})
				.then((response) => {
					if (!response.ok) throw response;
					return response.json();
				})
				.then((json) => {
					this.tags = json;
					this.loading = false;
				})
				.catch((err) => {
					this.loading = false;

					if (err !== skipFetchTags) {
						this.getErrorMessage(err).then((msg) => {
							this.showErrorDialog(msg);
						});
					}
				});
		},
		searchBookmarks() {
			this.page = 1;
			this.loadData();
		},
		changePage(page) {
			this.page = page;
			this.$refs.bookmarksGrid.scrollTop = 0;
			this.loadData();
		},
		toggleEditMode() {
			this.selection = [];
			this.editMode = !this.editMode;
		},
		toggleSelection(item) {
			var idx = this.selection.findIndex((el) => el.id === item.id);
			if (idx === -1) this.selection.push(item);
			else this.selection.splice(idx, 1);
		},
		isSelected(bookId) {
			return this.selection.findIndex((el) => el.id === bookId) > -1;
		},
		dialogTagClicked(event, tag) {
			if (!this.dialogTags.editMode) {
				this.filterTag(tag.name, event.altKey);
			} else {
				this.dialogTags.visible = false;
				this.showDialogRenameTag(tag);
			}
		},
		bookmarkTagClicked(event, tagName) {
			this.filterTag(tagName, event.altKey);
		},
		filterTag(tagName, excludeMode) {
			// Set default parameter
			excludeMode = typeof excludeMode === "boolean" ? excludeMode : false;

			if (this.dialogTags.editMode) {
				return;
			}

			if (tagName === "*") {
				this.search = excludeMode ? "-tag:*" : "tag:*";
				this.page = 1;
				this.loadData();
				return;
			}

			var rxSpace = /\s+/g,
				includeTag = rxSpace.test(tagName)
					? `tag:"${tagName}"`
					: `tag:${tagName}`,
				excludeTag = "-" + includeTag,
				rxIncludeTag = new RegExp(`(^|\\s)${includeTag}`, "ig"),
				rxExcludeTag = new RegExp(`(^|\\s)${excludeTag}`, "ig"),
				search = this.search;

			search = search.replace("-tag:*", "");
			search = search.replace("tag:*", "");
			search = search.trim();

			if (excludeMode) {
				if (rxExcludeTag.test(search)) {
					return;
				}

				if (rxIncludeTag.test(search)) {
					this.search = search.replace(rxIncludeTag, "$1" + excludeTag);
				} else {
					search += ` ${excludeTag}`;
					this.search = search.trim();
				}
			} else {
				if (rxIncludeTag.test(search)) {
					return;
				}

				if (rxExcludeTag.test(search)) {
					this.search = search.replace(rxExcludeTag, "$1" + includeTag);
				} else {
					search += ` ${includeTag}`;
					this.search = search.trim();
				}
			}

			this.page = 1;
			this.loadData();
		},
		showDialogAdd() {
			this.showDialog({
				title: "New Bookmark",
				content: "Create a new bookmark",
				fields: [
					{
						name: "url",
						label: "Url, start with http://...",
					},
					{
						name: "title",
						label: "Custom title (optional)",
					},
					{
						name: "excerpt",
						label: "Custom excerpt (optional)",
						type: "area",
					},
					{
						name: "tags",
						label: "Comma separated tags (optional)",
						separator: ",",
						dictionary: this.tags.map((tag) => tag.name),
					},
					{
						name: "create_archive",
						label: "Create archive",
						type: "check",
						value: this.appOptions.UseArchive,
					},
					{
						name: "create_ebook",
						label: "Create Ebook",
						type: "check",
						value: this.appOptions.CreateEbook,
					},
					{
						name: "makePublic",
						label: "Make archive publicly available",
						type: "check",
						value: this.appOptions.MakePublic,
					},
				],
				mainText: "OK",
				secondText: "Cancel",
				mainClick: (data) => {
					// Make sure URL is not empty
					if (data.url.trim() === "") {
						this.showErrorDialog("URL must not empty");
						return;
					}

					// Prepare tags
					var tags = data.tags
						.toLowerCase()
						.replace(/\s+/g, " ")
						.split(/\s*,\s*/g)
						.filter((tag) => tag.trim() !== "")
						.map((tag) => {
							return {
								name: tag.trim(),
							};
						});

					// Send data
					var data = {
						url: data.url.trim(),
						title: data.title.trim(),
						excerpt: data.excerpt.trim(),
						public: data.makePublic ? 1 : 0,
						tags: tags,
						create_archive: data.create_archive,
						create_ebook: data.create_ebook,
					};

					this.dialog.loading = true;
					fetch(new URL("api/bookmarks", document.baseURI), {
						method: "post",
						body: JSON.stringify(data),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response.json();
						})
						.then((json) => {
							this.dialog.loading = false;
							this.dialog.visible = false;
							this.bookmarks.splice(0, 0, json);
						})
						.catch((err) => {
							this.dialog.loading = false;
							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
		showDialogEdit(item) {
			// Check the item
			if (typeof item !== "object") return;

			var id = typeof item.id === "number" ? item.id : 0,
				index = typeof item.index === "number" ? item.index : -1;

			if (id < 1 || index < 0) return;

			// Get the existing bookmark value
			var book = JSON.parse(JSON.stringify(this.bookmarks[index])),
				strTags = book.tags.map((tag) => tag.name).join(", ");

			this.showDialog({
				title: "Edit Bookmark",
				content: "Edit the bookmark's data",
				showLabel: true,
				fields: [
					{
						name: "url",
						label: "Url",
						value: book.url,
					},
					{
						name: "title",
						label: "Title",
						value: book.title,
					},
					{
						name: "excerpt",
						label: "Excerpt",
						type: "area",
						value: book.excerpt,
					},
					{
						name: "tags",
						label: "Tags",
						value: strTags,
						separator: ",",
						dictionary: this.tags.map((tag) => tag.name),
					},
					{
						name: "makePublic",
						label: "Make archive publicly available",
						type: "check",
						value: book.public >= 1,
					},
				],
				mainText: "OK",
				secondText: "Cancel",
				mainClick: (data) => {
					// Validate input
					if (data.title.trim() === "") return;

					// Prepare tags
					var tags = data.tags
						.toLowerCase()
						.replace(/\s+/g, " ")
						.split(/\s*,\s*/g)
						.filter((tag) => tag.trim() !== "")
						.map((tag) => {
							return {
								name: tag.trim(),
							};
						});

					// Set new data
					book.url = data.url.trim();
					book.title = data.title.trim();
					book.excerpt = data.excerpt.trim();
					book.public = data.makePublic ? 1 : 0;
					book.tags = tags;

					// Send data
					this.dialog.loading = true;
					fetch(new URL("api/bookmarks", document.baseURI), {
						method: "put",
						body: JSON.stringify(book),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response.json();
						})
						.then((json) => {
							this.dialog.loading = false;
							this.dialog.visible = false;
							this.bookmarks.splice(index, 1, json);
						})
						.catch((err) => {
							this.dialog.loading = false;
							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
		showDialogDelete(items) {
			// Check and filter items
			if (typeof items !== "object") return;
			if (!Array.isArray(items)) items = [items];

			items = items.filter((item) => {
				var id = typeof item.id === "number" ? item.id : 0,
					index = typeof item.index === "number" ? item.index : -1;

				return id > 0 && index > -1;
			});

			if (items.length === 0) return;

			// Split ids and indices
			var ids = items.map((item) => item.id),
				indices = items.map((item) => item.index).sort((a, b) => b - a);

			// Create title and content
			var title = "Delete Bookmarks",
				content =
					"Delete the selected bookmarks ? This action is irreversible.";

			if (items.length === 1) {
				title = "Delete Bookmark";
				content = "Are you sure ? This action is irreversible.";
			}

			// Show dialog
			this.showDialog({
				title: title,
				content: content,
				mainText: "Yes",
				secondText: "No",
				mainClick: () => {
					this.dialog.loading = true;
					fetch(new URL("api/bookmarks", document.baseURI), {
						method: "delete",
						body: JSON.stringify(ids),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response;
						})
						.then(() => {
							this.selection = [];
							this.editMode = false;
							this.dialog.loading = false;
							this.dialog.visible = false;
							indices.forEach((index) => this.bookmarks.splice(index, 1));

							if (this.bookmarks.length < 20) {
								this.loadData(false);
							}
						})
						.catch((err) => {
							this.selection = [];
							this.editMode = false;
							this.dialog.loading = false;

							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
		ebookGenerate(items) {
			// Check and filter items
			if (typeof items !== "object") return;
			if (!Array.isArray(items)) items = [items];

			items = items.filter((item) => {
				var id = typeof item.id === "number" ? item.id : 0,
					index = typeof item.index === "number" ? item.index : -1;

				return id > 0 && index > -1;
			});

			if (items.length === 0) return;

			// define variable and send request
			var ids = items.map((item) => item.id);
			var data = {
				ids: ids,
				create_archive: false,
				keep_metadata: true,
				create_ebook: true,
				skip_exist: true,
			};
			this.loading = true;
			fetch(new URL("api/v1/bookmarks/cache", document.baseURI), {
				method: "put",
				body: JSON.stringify(data),
				headers: {
					"Content-Type": "application/json",
					Authorization: "Bearer " + localStorage.getItem("shiori-token"),
				},
			})
				.then((response) => {
					if (!response.ok) throw response;
					return response.json();
				})
				.then((json) => {
					this.selection = [];
					this.editMode = false;
					json.message.forEach((book) => {
						// download ebooks
						const id = book.id;
						if (book.hasEbook) {
							const ebook_url = new URL(
								`bookmark/${id}/ebook`,
								document.baseURI,
							);
							const downloadLink = document.createElement("a");
							downloadLink.href = ebook_url.toString();
							downloadLink.download = `${book.title}.epub`;
							downloadLink.click();
						}

						var item = items.find((el) => el.id === book.id);
						this.bookmarks.splice(item.index, 1, book);
					});
				})
				.catch((err) => {
					this.selection = [];
					this.editMode = false;
					this.getErrorMessage(err).then((msg) => {
						this.showErrorDialog(msg);
					});
				})
				.finally(() => {
					this.loading = false;
				});
		},
		showDialogUpdateCache(items) {
			// Check and filter items
			if (typeof items !== "object") return;
			if (!Array.isArray(items)) items = [items];

			items = items.filter((item) => {
				var id = typeof item.id === "number" ? item.id : 0,
					index = typeof item.index === "number" ? item.index : -1;

				return id > 0 && index > -1;
			});

			if (items.length === 0) return;

			// Show dialog
			var ids = items.map((item) => item.id);

			this.showDialog({
				title: "Update Cache",
				content:
					"Update cache for selected bookmarks ? This action is irreversible.",
				fields: [
					{
						name: "keep_metadata",
						label: "Keep the old title and excerpt",
						type: "check",
						value: this.appOptions.KeepMetadata,
					},
					{
						name: "create_archive",
						label: "Update archive as well",
						type: "check",
						value: this.appOptions.UseArchive,
					},
					{
						name: "create_ebook",
						label: "Update Ebook as well",
						type: "check",
						value: this.appOptions.CreateEbook,
					},
				],
				mainText: "Yes",
				secondText: "No",
				mainClick: (data) => {
					var data = {
						ids: ids,
						create_archive: data.create_archive,
						keep_metadata: data.keep_metadata,
						create_ebook: data.create_ebook,
						skip_exist: false,
					};

					this.dialog.loading = true;
					fetch(new URL("api/v1/bookmarks/cache", document.baseURI), {
						method: "put",
						body: JSON.stringify(data),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response.json();
						})
						.then((json) => {
							this.selection = [];
							this.editMode = false;
							this.dialog.loading = false;
							this.dialog.visible = false;

							let faildedUpdateArchives = [];
							let faildedCreateEbook = [];
							json.message.forEach((book) => {
								var item = items.find((el) => el.id === book.id);
								this.bookmarks.splice(item.index, 1, book);

								if (data.create_archive && !book.hasArchive) {
									faildedUpdateArchives.push(book.id);
									console.error(
										"can't update archive for bookmark id",
										book.id,
									);
								}
								if (data.create_ebook && !book.hasEbook) {
									faildedCreateEbook.push(book.id);
									console.error("can't update ebook for bookmark id:", book.id);
								}
							});
							if (
								faildedCreateEbook.length > 0 ||
								faildedUpdateArchives.length > 0
							) {
								this.showDialog({
									title: `Bookmarks Id that Update Action Faild`,
									content: `Not all bookmarks could have their contents updated, but no files were overwritten.`,
									mainText: "OK",
									mainClick: () => {
										this.dialog.visible = false;
									},
								});
							}
						})
						.catch((err) => {
							this.selection = [];
							this.editMode = false;
							this.dialog.loading = false;

							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
		showDialogAddTags(items) {
			// Check and filter items
			if (typeof items !== "object") return;
			if (!Array.isArray(items)) items = [items];

			items = items.filter((item) => {
				var id = typeof item.id === "number" ? item.id : 0,
					index = typeof item.index === "number" ? item.index : -1;

				return id > 0 && index > -1;
			});

			if (items.length === 0) return;

			// Show dialog
			this.showDialog({
				title: "Add New Tags",
				content: "Add new tags to selected bookmarks",
				fields: [
					{
						name: "tags",
						label: "Comma separated tags",
						value: "",
						separator: ",",
						dictionary: this.tags.map((tag) => tag.name),
					},
				],
				mainText: "OK",
				secondText: "Cancel",
				mainClick: (data) => {
					// Validate input
					var tags = data.tags
						.toLowerCase()
						.replace(/\s+/g, " ")
						.split(/\s*,\s*/g)
						.filter((tag) => tag.trim() !== "")
						.map((tag) => {
							return {
								name: tag.trim(),
							};
						});

					if (tags.length === 0) return;

					// Send data
					var request = {
						ids: items.map((item) => item.id),
						tags: tags,
					};

					this.dialog.loading = true;
					fetch(new URL("api/bookmarks/tags", document.baseURI), {
						method: "put",
						body: JSON.stringify(request),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response.json();
						})
						.then((json) => {
							this.selection = [];
							this.editMode = false;
							this.dialog.loading = false;
							this.dialog.visible = false;

							json.forEach((book) => {
								var item = items.find((el) => el.id === book.id);
								this.bookmarks.splice(item.index, 1, book);
							});
						})
						.catch((err) => {
							this.selection = [];
							this.editMode = false;
							this.dialog.loading = false;

							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
		showDialogTags() {
			this.dialogTags.visible = true;
			this.dialogTags.editMode = false;
			this.dialogTags.secondText = this.activeAccount.owner
				? "Rename Tags"
				: "";
		},
		showDialogRenameTag(tag) {
			this.showDialog({
				title: "Rename Tag",
				content: `Change the name for tag "#${tag.name}"`,
				fields: [
					{
						name: "newName",
						label: "New tag name",
						value: tag.name,
					},
				],
				mainText: "OK",
				secondText: "Cancel",
				secondClick: () => {
					this.dialog.visible = false;
					this.dialogTags.visible = true;
				},
				escPressed: () => {
					this.dialog.visible = false;
					this.dialogTags.visible = true;
				},
				mainClick: (data) => {
					// Save the old query
					var rxSpace = /\s+/g,
						oldTagQuery = rxSpace.test(tag.name)
							? `"#${tag.name}"`
							: `#${tag.name}`,
						newTagQuery = rxSpace.test(data.newName)
							? `"#${data.newName}"`
							: `#${data.newName}`;

					// Send data
					var newData = {
						id: tag.id,
						name: data.newName,
					};

					this.dialog.loading = true;
					fetch(new URL("api/tag", document.baseURI), {
						method: "PUT",
						body: JSON.stringify(newData),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response.json();
						})
						.then(() => {
							tag.name = data.newName;

							this.dialog.loading = false;
							this.dialog.visible = false;
							this.dialogTags.visible = true;
							this.dialogTags.editMode = false;
							this.tags.sort((a, b) => {
								var aName = a.name.toLowerCase(),
									bName = b.name.toLowerCase();

								if (aName < bName) return -1;
								else if (aName > bName) return 1;
								else return 0;
							});

							if (this.search.includes(oldTagQuery)) {
								this.search = this.search.replace(oldTagQuery, newTagQuery);
								this.loadData();
							}
						})
						.catch((err) => {
							this.dialog.loading = false;
							this.dialogTags.visible = false;
							this.dialogTags.editMode = false;
							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
	},
	mounted() {
		// Prepare history state watcher
		var stateWatcher = (e) => {
			var state = e.state || {},
				activePage = state.activePage || "page-home",
				search = state.search || "",
				page = state.page || 1;

			if (activePage !== "page-home") return;

			this.page = page;
			this.search = search;
			this.loadData(false);
		};

		window.addEventListener("popstate", stateWatcher);
		this.$once("hook:beforeDestroy", () => {
			window.removeEventListener("popstate", stateWatcher);
		});

		// Set initial parameter
		var url = new Url();
		this.search = url.query.search || "";
		this.page = url.query.page || 1;

		this.loadData(false, true);
	},
};


code:
var template = `

Settings

            Display

                <input type="checkbox" v-model="appOptions.ShowId" @change="saveSetting">
                Show bookmark's ID

                <input type="checkbox" v-model="appOptions.ListMode" @change="saveSetting">
                Display bookmarks as list

                <input type="checkbox" v-model="appOptions.HideThumbnail" @change="saveSetting">
                Hide thumbnail image

                <input type="checkbox" v-model="appOptions.HideExcerpt" @change="saveSetting">
                Hide bookmark's excerpt

                <input type="checkbox" v-model="appOptions.NightMode" @change="saveSetting">
                Use dark theme

            Bookmarks

                <input type="checkbox" v-model="appOptions.KeepMetadata" @change="saveSetting">
                Keep bookmark's metadata when updating

                <input type="checkbox" v-model="appOptions.UseArchive" @change="saveSetting">
                Create archive by default

                <input type="checkbox" v-model="appOptions.CreateEbook" @change="saveSetting">
                Create ebook by default

                <input type="checkbox" v-model="appOptions.MakePublic" @change="saveSetting">
                Make archive publicly available by default

            Accounts

No accounts registered

{{account.username}}
                        (owner)

                Refresh accounts
                Add new account

    <custom-dialog v-bind="dialog"/>

`;

import customDialog from "../component/dialog.js";
import basePage from "./base.js";

export default {
	template: template,
	mixins: [basePage],
	components: {
		customDialog,
	},
	data() {
		return {
			loading: false,
			accounts: [],
		};
	},
	methods: {
		saveSetting() {
			let options = {
				ShowId: this.appOptions.ShowId,
				ListMode: this.appOptions.ListMode,
				HideThumbnail: this.appOptions.HideThumbnail,
				HideExcerpt: this.appOptions.HideExcerpt,
				NightMode: this.appOptions.NightMode,
			};

			if (this.activeAccount.owner) {
				options = {
					...options,
					KeepMetadata: this.appOptions.KeepMetadata,
					UseArchive: this.appOptions.UseArchive,
					CreateEbook: this.appOptions.CreateEbook,
					MakePublic: this.appOptions.MakePublic,
				};
			}

			this.$emit("setting-changed", options);
			//request
			fetch(new URL("/api/v1/auth/account", document.baseURI), {
				method: "PATCH",
				body: JSON.stringify({
					config: this.appOptions,
				}),
				headers: {
					"Content-Type": "application/json",
					Authorization: "Bearer " + localStorage.getItem("shiori-token"),
				},
			})
				.then((response) => {
					if (!response.ok) throw response;
					return response.json();
				})
				.then((responseData) => {
					const responseString = JSON.stringify(responseData.message);
					localStorage.setItem("shiori-account", responseString);
				})
				.catch((err) => {
					this.getErrorMessage(err).then((msg) => {
						this.showErrorDialog(msg);
					});
				});
		},
		loadAccounts() {
			if (this.loading) return;

			this.loading = true;
			fetch(new URL("api/accounts", document.baseURI), {
				headers: {
					"Content-Type": "application/json",
					Authorization: "Bearer " + localStorage.getItem("shiori-token"),
				},
			})
				.then((response) => {
					if (!response.ok) throw response;
					return response.json();
				})
				.then((json) => {
					this.loading = false;
					this.accounts = json;
				})
				.catch((err) => {
					this.loading = false;
					this.getErrorMessage(err).then((msg) => {
						this.showErrorDialog(msg);
					});
				});
		},
		showDialogNewAccount() {
			this.showDialog({
				title: "New Account",
				content: "Input new account's data :",
				fields: [
					{
						name: "username",
						label: "Username",
						value: "",
					},
					{
						name: "password",
						label: "Password",
						type: "password",
						value: "",
					},
					{
						name: "repeat",
						label: "Repeat password",
						type: "password",
						value: "",
					},
					{
						name: "visitor",
						label: "This account is for visitor",
						type: "check",
						value: false,
					},
				],
				mainText: "OK",
				secondText: "Cancel",
				mainClick: (data) => {
					if (data.username === "") {
						this.showErrorDialog("Username must not empty");
						return;
					}

					if (data.password === "") {
						this.showErrorDialog("Password must not empty");
						return;
					}

					if (data.password !== data.repeat) {
						this.showErrorDialog("Password does not match");
						return;
					}

					var request = {
						username: data.username,
						password: data.password,
						owner: !data.visitor,
					};

					this.dialog.loading = true;
					fetch(new URL("api/accounts", document.baseURI), {
						method: "post",
						body: JSON.stringify(request),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response;
						})
						.then(() => {
							this.dialog.loading = false;
							this.dialog.visible = false;

							this.accounts.push({
								username: data.username,
								owner: !data.visitor,
							});
							this.accounts.sort((a, b) => {
								var nameA = a.username.toLowerCase(),
									nameB = b.username.toLowerCase();

								if (nameA < nameB) {
									return -1;
								}

								if (nameA > nameB) {
									return 1;
								}

								return 0;
							});
						})
						.catch((err) => {
							this.dialog.loading = false;
							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
		showDialogChangePassword(account) {
			this.showDialog({
				title: "Change Password",
				content: "Input new password :",
				fields: [
					{
						name: "oldPassword",
						label: "Old password",
						type: "password",
						value: "",
					},
					{
						name: "password",
						label: "New password",
						type: "password",
						value: "",
					},
					{
						name: "repeat",
						label: "Repeat password",
						type: "password",
						value: "",
					},
				],
				mainText: "OK",
				secondText: "Cancel",
				mainClick: (data) => {
					if (data.oldPassword === "") {
						this.showErrorDialog("Old password must not empty");
						return;
					}

					if (data.password === "") {
						this.showErrorDialog("New password must not empty");
						return;
					}

					if (data.password !== data.repeat) {
						this.showErrorDialog("Password does not match");
						return;
					}

					var request = {
						username: account.username,
						oldPassword: data.oldPassword,
						newPassword: data.password,
						owner: account.owner,
					};

					this.dialog.loading = true;
					fetch(new URL("api/accounts", document.baseURI), {
						method: "put",
						body: JSON.stringify(request),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response;
						})
						.then(() => {
							this.dialog.loading = false;
							this.dialog.visible = false;
						})
						.catch((err) => {
							this.dialog.loading = false;
							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
		showDialogDeleteAccount(account, idx) {
			this.showDialog({
				title: "Delete Account",
				content: `Delete account "${account.username}" ?`,
				mainText: "Yes",
				secondText: "No",
				mainClick: () => {
					this.dialog.loading = true;
					fetch(`/api/accounts`, {
						method: "delete",
						body: JSON.stringify([account.username]),
						headers: {
							"Content-Type": "application/json",
							Authorization: "Bearer " + localStorage.getItem("shiori-token"),
						},
					})
						.then((response) => {
							if (!response.ok) throw response;
							return response;
						})
						.then(() => {
							this.dialog.loading = false;
							this.dialog.visible = false;
							this.accounts.splice(idx, 1);
						})
						.catch((err) => {
							this.dialog.loading = false;
							this.getErrorMessage(err).then((msg) => {
								this.showErrorDialog(msg);
							});
						});
				},
			});
		},
	},
	mounted() {
		this.loadAccounts();
	},
};


code:
/*!
 * Lightweight URL manipulation with JavaScript
 * This library is independent of any other libraries and has pretty simple
 * interface and lightweight code-base.
 * Some ideas of query string parsing had been taken from Jan Wolter
 * @see http://unixpapa.com/js/querystring.html
 *
 * @license MIT
 * @author Mykhailo Stadnyk <mikhus@gmail.com>
 */
(function (ns) {
	'use strict';

	var RX_PROTOCOL = /^[a-z]+:/;
	var RX_PORT = /[-a-z0-9]+(\.[-a-z0-9])*:\d+/i;
	var RX_CREDS = /\/\/(.*?)(?::(.*?))?@/;
	var RX_WIN = /^win/i;
	var RX_PROTOCOL_REPL = /:$/;
	var RX_QUERY_REPL = /^\?/;
	var RX_HASH_REPL = /^#/;
	var RX_PATH = /(.*\/)/;
	var RX_PATH_FIX = /^\/{2,}/;
	var RX_SINGLE_QUOTE = /'/g;
	var RX_DECODE_1 = /%([ef][0-9a-f])%([89ab][0-9a-f])%([89ab][0-9a-f])/gi;
	var RX_DECODE_2 = /%([cd][0-9a-f])%([89ab][0-9a-f])/gi;
	var RX_DECODE_3 = /%([0-7][0-9a-f])/gi;
	var RX_PLUS = /\+/g;
	var RX_PATH_SEMI = /^\w:$/;
	var RX_URL_TEST = /[^/#?]/;

	// configure given url options
	function urlConfig(url) {
		var config = {
			path: true,
			query: true,
			hash: true
		};

		if (!url) {
			return config;
		}

		if (RX_PROTOCOL.test(url)) {
			config.protocol = true;
			config.host = true;

			if (RX_PORT.test(url)) {
				config.port = true;
			}

			if (RX_CREDS.test(url)) {
				config.user = true;
				config.pass = true;
			}
		}

		return config;
	}

	var isNode = typeof window === 'undefined' &&
		typeof global !== 'undefined' &&
		typeof require === 'function';

	// Trick to bypass Webpack's require at compile time
	var nodeRequire = isNode ? ns['require'] : null;

	// mapping between what we want and <a> element properties
	var map = {
		protocol: 'protocol',
		host: 'hostname',
		port: 'port',
		path: 'pathname',
		query: 'search',
		hash: 'hash'
	};

	// jscs: disable
    /**
     * default ports as defined by http://url.spec.whatwg.org/#default-port
     * We need them to fix IE behavior, @see https://github.com/Mikhus/jsurl/issues/2
     */
	// jscs: enable
	var defaultPorts = {
		ftp: 21,
		gopher: 70,
		http: 80,
		https: 443,
		ws: 80,
		wss: 443
	};

	var _currNodeUrl;
	function getCurrUrl() {
		if (isNode) {
			if (!_currNodeUrl) {
				_currNodeUrl = ('file://' +
					(process.platform.match(RX_WIN) ? '/' : '') +
					nodeRequire('fs').realpathSync('.')
				);
			}
			return _currNodeUrl;
		} else {
			return document.location.href;
		}
	}

	function parse(self, url, absolutize) {
		var link, i, auth;

		if (!url) {
			url = getCurrUrl();
		}

		if (isNode) {
			link = nodeRequire('url').parse(url);
		}

		else {
			link = document.createElement('a');
			link.href = url;
		}

		var config = urlConfig(url);

		auth = url.match(RX_CREDS) || [];

		for (i in map) {
			if (config[i]) {
				self[i] = link[map[i]] || '';
			}

			else {
				self[i] = '';
			}
		}

		// fix-up some parts
		self.protocol = self.protocol.replace(RX_PROTOCOL_REPL, '');
		self.query = self.query.replace(RX_QUERY_REPL, '');
		self.hash = decode(self.hash.replace(RX_HASH_REPL, ''));
		self.user = decode(auth[1] || '');
		self.pass = decode(auth[2] || '');
		/* jshint ignore:start */
		self.port = (
			// loosely compare because port can be a string
			defaultPorts[self.protocol] == self.port || self.port == 0
		) ? '' : self.port; // IE fix, Android browser fix
		/* jshint ignore:end */

		if (!config.protocol && RX_URL_TEST.test(url.charAt(0))) {
			self.path = url.split('?')[0].split('#')[0];
		}

		if (!config.protocol && absolutize) {
			// is IE and path is relative
			var base = new Url(getCurrUrl().match(RX_PATH)[0]);
			var basePath = base.path.split('/');
			var selfPath = self.path.split('/');
			var props = ['protocol', 'user', 'pass', 'host', 'port'];
			var s = props.length;

			basePath.pop();

			for (i = 0; i < s; i++) {
				self[props[i]] = base[props[i]];
			}

			while (selfPath[0] === '..') { // skip all "../
				basePath.pop();
				selfPath.shift();
			}

			self.path =
				(url.charAt(0) !== '/' ? basePath.join('/') : '') +
				'/' + selfPath.join('/')
				;
		}

		self.path = self.path.replace(RX_PATH_FIX, '/');

		self.paths(self.paths());

		self.query = new QueryString(self.query);
	}

	function encode(s) {
		return encodeURIComponent(s).replace(RX_SINGLE_QUOTE, '%27');
	}

	function decode(s) {
		s = s.replace(RX_PLUS, ' ');
		s = s.replace(RX_DECODE_1, function (code, hex1, hex2, hex3) {
			var n1 = parseInt(hex1, 16) - 0xE0;
			var n2 = parseInt(hex2, 16) - 0x80;

			if (n1 === 0 && n2 < 32) {
				return code;
			}

			var n3 = parseInt(hex3, 16) - 0x80;
			var n = (n1 << 12) + (n2 << 6) + n3;

			if (n > 0xFFFF) {
				return code;
			}

			return String.fromCharCode(n);
		});
		s = s.replace(RX_DECODE_2, function (code, hex1, hex2) {
			var n1 = parseInt(hex1, 16) - 0xC0;

			if (n1 < 2) {
				return code;
			}

			var n2 = parseInt(hex2, 16) - 0x80;

			return String.fromCharCode((n1 << 6) + n2);
		});

		return s.replace(RX_DECODE_3, function (code, hex) {
			return String.fromCharCode(parseInt(hex, 16));
		});
	}

    /**
     * Class QueryString
     *
     * @param {string} qs - string representation of QueryString
     * @constructor
     */
	function QueryString(qs) {
		var parts = qs.split('&');

		for (var i = 0, s = parts.length; i < s; i++) {
			var keyVal = parts[i].split('=');
			var key = decodeURIComponent(keyVal[0].replace(RX_PLUS, ' '));

			if (!key) {
				continue;
			}

			var value = keyVal[1] !== undefined ? decode(keyVal[1]) : null;

			if (typeof this[key] === 'undefined') {
				this[key] = value;
			} else {
				if (!(this[key] instanceof Array)) {
					this[key] = [this[key]];
				}

				this[key].push(value);
			}
		}
	}

    /**
     * Converts QueryString object back to string representation
     *
     * @returns {string}
     */
	QueryString.prototype.toString = function () {
		var s = '';
		var e = encode;
		var i, ii;

		for (i in this) {
			var w = this[i];

			if (w instanceof Function || w === null) {
				continue;
			}

			if (w instanceof Array) {
				var len = w.length;

				if (len) {
					for (ii = 0; ii < len; ii++) {
						var v = w[ii];
						s += s ? '&' : '';
						s += e(i) + (v === undefined || v === null
							? ''
							: '=' + e(v));
					}
				}

				else {
					// parameter is an empty array, so treat as
					// an empty argument
					s += (s ? '&' : '') + e(i) + '=';
				}
			}

			else {
				s += s ? '&' : '';
				s += e(i) + (w === undefined ? '' : '=' + e(w));
			}
		}

		return s;
	};

    /**
     * Class Url
     *
     * @param {string} [url] - string URL representation
     * @param {boolean} [noTransform] - do not transform to absolute URL
     * @constructor
     */
	function Url(url, noTransform) {
		parse(this, url, !noTransform);
	}

    /**
     * Clears QueryString, making it contain no params at all
     *
     * @returns {Url}
     */
	Url.prototype.clearQuery = function () {
		for (var key in this.query) {
			if (!(this.query[key] instanceof Function)) {
				delete this.query[key];
			}
		}

		return this;
	};

    /**
     * Returns total number of parameters in QueryString
     *
     * @returns {number}
     */
	Url.prototype.queryLength = function () {
		var count = 0;

		for (var key in this.query) {
			if (!(this.query[key] instanceof Function)) {
				count++;
			}
		}

		return count;
	};

    /**
     * Returns true if QueryString contains no parameters, false otherwise
     *
     * @returns {boolean}
     */
	Url.prototype.isEmptyQuery = function () {
		return this.queryLength() === 0;
	};

    /**
     *
     * @param {Array} [paths] - an array pf path parts (if given will modify
     *                          Url.path property
     * @returns {Array} - an array representation of the Url.path property
     */
	Url.prototype.paths = function (paths) {
		var prefix = '';
		var i = 0;
		var s;

		if (paths && paths.length && paths + '' !== paths) {
			if (this.isAbsolute()) {
				prefix = '/';
			}

			for (s = paths.length; i < s; i++) {
				paths[i] = !i && RX_PATH_SEMI.test(paths[i])
					? paths[i]
					: encode(paths[i]);
			}

			this.path = prefix + paths.join('/');
		}

		paths = (this.path.charAt(0) === '/' ?
			this.path.slice(1) : this.path).split('/');

		for (i = 0, s = paths.length; i < s; i++) {
			paths[i] = decode(paths[i]);
		}

		return paths;
	};

    /**
     * Performs URL-specific encoding of the given string
     *
     * @method Url#encode
     * @param {string} s - string to encode
     * @returns {string}
     */
	Url.prototype.encode = encode;

    /**
     * Performs URL-specific decoding of the given encoded string
     *
     * @method Url#decode
     * @param {string} s - string to decode
     * @returns {string}
     */
	Url.prototype.decode = decode;

    /**
     * Checks if current URL is an absolute resource locator (globally absolute
     * or absolute path to current server)
     *
     * @returns {boolean}
     */
	Url.prototype.isAbsolute = function () {
		return this.protocol || this.path.charAt(0) === '/';
	};

    /**
     * Returns string representation of current Url object
     *
     * @returns {string}
     */
	Url.prototype.toString = function () {
		return (
			(this.protocol && (this.protocol + '://')) +
			(this.user && (
				encode(this.user) + (this.pass && (':' + encode(this.pass))
				) + '@')) +
			(this.host && this.host) +
			(this.port && (':' + this.port)) +
			(this.path && this.path) +
			(this.query.toString() && ('?' + this.query)) +
			(this.hash && ('#' + encode(this.hash)))
		);
	};

	ns[ns.exports ? 'exports' : 'Url'] = Url;
}(typeof module !== 'undefined' && module.exports ? module : window));