Merge branch 'main' into e2e
This commit is contained in:
		
						commit
						78f9d4835e
					
				
					 14 changed files with 146 additions and 44 deletions
				
			
		|  | @ -6,6 +6,7 @@ import ( | |||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"heckel.io/ntfy/log" | ||||
| 	"io/fs" | ||||
| 	"math" | ||||
| 	"net" | ||||
| 	"os" | ||||
|  | @ -35,6 +36,7 @@ var flagsServe = append( | |||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}), | ||||
| 	altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), | ||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), | ||||
|  | @ -99,6 +101,7 @@ func execServe(c *cli.Context) error { | |||
| 	listenHTTP := c.String("listen-http") | ||||
| 	listenHTTPS := c.String("listen-https") | ||||
| 	listenUnix := c.String("listen-unix") | ||||
| 	listenUnixMode := c.Int("listen-unix-mode") | ||||
| 	keyFile := c.String("key-file") | ||||
| 	certFile := c.String("cert-file") | ||||
| 	firebaseKeyFile := c.String("firebase-key-file") | ||||
|  | @ -219,6 +222,7 @@ func execServe(c *cli.Context) error { | |||
| 	conf.ListenHTTP = listenHTTP | ||||
| 	conf.ListenHTTPS = listenHTTPS | ||||
| 	conf.ListenUnix = listenUnix | ||||
| 	conf.ListenUnixMode = fs.FileMode(listenUnixMode) | ||||
| 	conf.KeyFile = keyFile | ||||
| 	conf.CertFile = certFile | ||||
| 	conf.FirebaseKeyFile = firebaseKeyFile | ||||
|  |  | |||
|  | @ -875,6 +875,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | |||
| | `listen-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`                                       | `:80`             | Listen address for the HTTP web server                                                                                                                                                                                          | | ||||
| | `listen-https`                             | `NTFY_LISTEN_HTTPS`                             | `[host]:port`                                       | -                 | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`.                                                                                                                               | | ||||
| | `listen-unix`                              | `NTFY_LISTEN_UNIX`                              | *filename*                                          | -                 | Path to a Unix socket to listen on                                                                                                                                                                                              | | ||||
| | `listen-unix-mode`                         | `NTFY_LISTEN_UNIX_MODE`                         | *file mode*                                         | *system default*  | File mode of the Unix socket, e.g. 0700 or 0777                                                                                                                                                                                 | | ||||
| | `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*                                          | -                 | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 | | ||||
| | `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*                                          | -                 | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 | | ||||
| | `firebase-key-file`                        | `NTFY_FIREBASE_KEY_FILE`                        | *filename*                                          | -                 | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm).                        | | ||||
|  |  | |||
|  | @ -9,14 +9,17 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | |||
| **Features:** | ||||
| 
 | ||||
| * Subscriptions can now have a display name ([#313](https://github.com/binwiederhier/ntfy/issues/313), thanks to [@wunter8](https://github.com/wunter8)) | ||||
| * Polling is now done with since=<id> API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165)) | ||||
| * Display name for UnifiedPush subscriptions ([#355](https://github.com/binwiederhier/ntfy/issues/355), thanks to [@wunter8](https://github.com/wunter8)) | ||||
| * Polling is now done with `since=<id>` API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165)) | ||||
| * Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket) | ||||
| * Move action buttons in notification cards ([#236](https://github.com/binwiederhier/ntfy/issues/236), thanks to [@wunter8](https://github.com/wunter8)) | ||||
| 
 | ||||
| **Bugs:** | ||||
| 
 | ||||
| * Long-click selecting of notifications doesn't scroll to the top anymore ([#235](https://github.com/binwiederhier/ntfy/issues/235), thanks to [@wunter8](https://github.com/wunter8)) | ||||
| * Add attachment and click URL extras to MESSAGE_RECEIVED broadcast ([#329](https://github.com/binwiederhier/ntfy/issues/329), thanks to [@wunter8](https://github.com/wunter8)) | ||||
| * Accessibility: Clear/choose service URL button in base URL dropdown now has a label ([#292](https://github.com/binwiederhier/ntfy/issues/292), thanks to [@mhameed](https://github.com/mhameed) for reporting) | ||||
| * Web: Switched "Pop" and "Pop Swoosh" sounds ([#352](https://github.com/binwiederhier/ntfy/issues/352), thanks to [@coma-toast](https://github.com/coma-toast) for reporting) | ||||
| 
 | ||||
| **Additional translations:** | ||||
| 
 | ||||
|  | @ -31,11 +34,13 @@ Thank you to [@wunter8](https://github.com/wunter8) for proactively picking up s | |||
| **Features:** | ||||
| 
 | ||||
| * Subscription display name for the web app ([#348](https://github.com/binwiederhier/ntfy/pull/348)) | ||||
| * Allow setting socket permissions via `--listen-unix-mode` ([#356](https://github.com/binwiederhier/ntfy/pull/356), thanks to [@koro666](https://github.com/koro666)) | ||||
| 
 | ||||
| **Bugs:** | ||||
| 
 | ||||
| * `ntfy user` commands don't work with `auth_file` but works with `auth-file` ([#344](https://github.com/binwiederhier/ntfy/issues/344), thanks to [@Histalek](https://github.com/Histalek) for reporting) | ||||
| * Ignore new draft HTTP `Priority` header  ([#351](https://github.com/binwiederhier/ntfy/issues/351), thanks to [@ksurl](https://github.com/ksurl) for reporting) | ||||
| * Delete expired attachments based on mod time instead of DB entry to avoid races (no ticket)  | ||||
| 
 | ||||
| **Documentation:** | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| package server | ||||
| 
 | ||||
| import ( | ||||
| 	"io/fs" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
|  | @ -52,6 +53,7 @@ type Config struct { | |||
| 	ListenHTTP                           string | ||||
| 	ListenHTTPS                          string | ||||
| 	ListenUnix                           string | ||||
| 	ListenUnixMode                       fs.FileMode | ||||
| 	KeyFile                              string | ||||
| 	CertFile                             string | ||||
| 	FirebaseKeyFile                      string | ||||
|  | @ -105,6 +107,7 @@ func NewConfig() *Config { | |||
| 		ListenHTTP:                           DefaultListenHTTP, | ||||
| 		ListenHTTPS:                          "", | ||||
| 		ListenUnix:                           "", | ||||
| 		ListenUnixMode:                       0, | ||||
| 		KeyFile:                              "", | ||||
| 		CertFile:                             "", | ||||
| 		FirebaseKeyFile:                      "", | ||||
|  |  | |||
|  | @ -2,16 +2,18 @@ package server | |||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"heckel.io/ntfy/util" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"regexp" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	fileIDRegex      = regexp.MustCompile(`^[-_A-Za-z0-9]+$`) | ||||
| 	fileIDRegex      = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength)) | ||||
| 	errInvalidFileID = errors.New("invalid file ID") | ||||
| 	errFileExists    = errors.New("file exists") | ||||
| ) | ||||
|  | @ -88,6 +90,25 @@ func (c *fileCache) Remove(ids ...string) error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // Expired returns a list of file IDs for expired files | ||||
| func (c *fileCache) Expired(olderThan time.Time) ([]string, error) { | ||||
| 	entries, err := os.ReadDir(c.dir) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	var ids []string | ||||
| 	for _, e := range entries { | ||||
| 		info, err := e.Info() | ||||
| 		if err != nil { | ||||
| 			continue | ||||
| 		} | ||||
| 		if info.ModTime().Before(olderThan) && fileIDRegex.MatchString(e.Name()) { | ||||
| 			ids = append(ids, e.Name()) | ||||
| 		} | ||||
| 	} | ||||
| 	return ids, nil | ||||
| } | ||||
| 
 | ||||
| func (c *fileCache) Size() int64 { | ||||
| 	c.mu.Lock() | ||||
| 	defer c.mu.Unlock() | ||||
|  |  | |||
|  | @ -8,6 +8,7 @@ import ( | |||
| 	"os" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
|  | @ -16,10 +17,10 @@ var ( | |||
| 
 | ||||
| func TestFileCache_Write_Success(t *testing.T) { | ||||
| 	dir, c := newTestFileCache(t) | ||||
| 	size, err := c.Write("abc", strings.NewReader("normal file"), util.NewFixedLimiter(999)) | ||||
| 	size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999)) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, int64(11), size) | ||||
| 	require.Equal(t, "normal file", readFile(t, dir+"/abc")) | ||||
| 	require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl")) | ||||
| 	require.Equal(t, int64(11), c.Size()) | ||||
| 	require.Equal(t, int64(10229), c.Remaining()) | ||||
| } | ||||
|  | @ -27,18 +28,18 @@ func TestFileCache_Write_Success(t *testing.T) { | |||
| func TestFileCache_Write_Remove_Success(t *testing.T) { | ||||
| 	dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024) | ||||
| 	for i := 0; i < 10; i++ {     // 10x999 = 9990 | ||||
| 		size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(make([]byte, 999))) | ||||
| 		size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999))) | ||||
| 		require.Nil(t, err) | ||||
| 		require.Equal(t, int64(999), size) | ||||
| 	} | ||||
| 	require.Equal(t, int64(9990), c.Size()) | ||||
| 	require.Equal(t, int64(250), c.Remaining()) | ||||
| 	require.FileExists(t, dir+"/abc1") | ||||
| 	require.FileExists(t, dir+"/abc5") | ||||
| 	require.FileExists(t, dir+"/abcdefghijk1") | ||||
| 	require.FileExists(t, dir+"/abcdefghijk5") | ||||
| 
 | ||||
| 	require.Nil(t, c.Remove("abc1", "abc5")) | ||||
| 	require.NoFileExists(t, dir+"/abc1") | ||||
| 	require.NoFileExists(t, dir+"/abc5") | ||||
| 	require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5")) | ||||
| 	require.NoFileExists(t, dir+"/abcdefghijk1") | ||||
| 	require.NoFileExists(t, dir+"/abcdefghijk5") | ||||
| 	require.Equal(t, int64(7992), c.Size()) | ||||
| 	require.Equal(t, int64(2248), c.Remaining()) | ||||
| } | ||||
|  | @ -46,27 +47,50 @@ func TestFileCache_Write_Remove_Success(t *testing.T) { | |||
| func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) { | ||||
| 	dir, c := newTestFileCache(t) | ||||
| 	for i := 0; i < 10; i++ { | ||||
| 		size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(oneKilobyteArray)) | ||||
| 		size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray)) | ||||
| 		require.Nil(t, err) | ||||
| 		require.Equal(t, int64(1024), size) | ||||
| 	} | ||||
| 	_, err := c.Write("abc11", bytes.NewReader(oneKilobyteArray)) | ||||
| 	_, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray)) | ||||
| 	require.Equal(t, util.ErrLimitReached, err) | ||||
| 	require.NoFileExists(t, dir+"/abc11") | ||||
| 	require.NoFileExists(t, dir+"/abcdefghijkX") | ||||
| } | ||||
| 
 | ||||
| func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) { | ||||
| 	dir, c := newTestFileCache(t) | ||||
| 	_, err := c.Write("abc", bytes.NewReader(make([]byte, 1025))) | ||||
| 	_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1025))) | ||||
| 	require.Equal(t, util.ErrLimitReached, err) | ||||
| 	require.NoFileExists(t, dir+"/abc") | ||||
| 	require.NoFileExists(t, dir+"/abcdefghijkl") | ||||
| } | ||||
| 
 | ||||
| func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) { | ||||
| 	dir, c := newTestFileCache(t) | ||||
| 	_, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) | ||||
| 	_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) | ||||
| 	require.Equal(t, util.ErrLimitReached, err) | ||||
| 	require.NoFileExists(t, dir+"/abc") | ||||
| 	require.NoFileExists(t, dir+"/abcdefghijkl") | ||||
| } | ||||
| 
 | ||||
| func TestFileCache_RemoveExpired(t *testing.T) { | ||||
| 	dir, c := newTestFileCache(t) | ||||
| 	_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001))) | ||||
| 	require.Nil(t, err) | ||||
| 	_, err = c.Write("notdeleted12", bytes.NewReader(make([]byte, 1001))) | ||||
| 	require.Nil(t, err) | ||||
| 
 | ||||
| 	modTime := time.Now().Add(-1 * 4 * time.Hour) | ||||
| 	require.Nil(t, os.Chtimes(dir+"/abcdefghijkl", modTime, modTime)) | ||||
| 
 | ||||
| 	olderThan := time.Now().Add(-1 * 3 * time.Hour) | ||||
| 	ids, err := c.Expired(olderThan) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, []string{"abcdefghijkl"}, ids) | ||||
| 	require.Nil(t, c.Remove(ids...)) | ||||
| 	require.NoFileExists(t, dir+"/abcdefghijkl") | ||||
| 	require.FileExists(t, dir+"/notdeleted12") | ||||
| 
 | ||||
| 	ids, err = c.Expired(olderThan) | ||||
| 	require.Nil(t, err) | ||||
| 	require.Empty(t, ids) | ||||
| } | ||||
| 
 | ||||
| func newTestFileCache(t *testing.T) (dir string, cache *fileCache) { | ||||
|  |  | |||
|  | @ -85,7 +85,6 @@ const ( | |||
| 	selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` | ||||
| 	selectTopicsQuery               = `SELECT topic FROM messages GROUP BY topic` | ||||
| 	selectAttachmentsSizeQuery      = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?` | ||||
| 	selectAttachmentsExpiredQuery   = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?` | ||||
| ) | ||||
| 
 | ||||
| // Schema management queries | ||||
|  | @ -409,26 +408,6 @@ func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) { | |||
| 	return size, nil | ||||
| } | ||||
| 
 | ||||
| func (c *messageCache) AttachmentsExpired() ([]string, error) { | ||||
| 	rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix()) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
| 	ids := make([]string, 0) | ||||
| 	for rows.Next() { | ||||
| 		var id string | ||||
| 		if err := rows.Scan(&id); err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		ids = append(ids, id) | ||||
| 	} | ||||
| 	if err := rows.Err(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return ids, nil | ||||
| } | ||||
| 
 | ||||
| func readMessages(rows *sql.Rows) ([]*message, error) { | ||||
| 	defer rows.Close() | ||||
| 	messages := make([]*message, 0) | ||||
|  |  | |||
|  | @ -344,10 +344,6 @@ func testCacheAttachments(t *testing.T, c *messageCache) { | |||
| 	size, err = c.AttachmentBytesUsed("5.6.7.8") | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, int64(0), size) | ||||
| 
 | ||||
| 	ids, err := c.AttachmentsExpired() | ||||
| 	require.Nil(t, err) | ||||
| 	require.Equal(t, []string{"m1"}, ids) | ||||
| } | ||||
| 
 | ||||
| func TestSqliteCache_Migration_From0(t *testing.T) { | ||||
|  |  | |||
|  | @ -204,9 +204,18 @@ func (s *Server) Run() error { | |||
| 			os.Remove(s.config.ListenUnix) | ||||
| 			s.unixListener, err = net.Listen("unix", s.config.ListenUnix) | ||||
| 			if err != nil { | ||||
| 				s.mu.Unlock() | ||||
| 				errChan <- err | ||||
| 				return | ||||
| 			} | ||||
| 			defer s.unixListener.Close() | ||||
| 			if s.config.ListenUnixMode > 0 { | ||||
| 				if err := os.Chmod(s.config.ListenUnix, s.config.ListenUnixMode); err != nil { | ||||
| 					s.mu.Unlock() | ||||
| 					errChan <- err | ||||
| 					return | ||||
| 				} | ||||
| 			} | ||||
| 			s.mu.Unlock() | ||||
| 			httpServer := &http.Server{Handler: mux} | ||||
| 			errChan <- httpServer.Serve(s.unixListener) | ||||
|  | @ -1107,8 +1116,9 @@ func (s *Server) updateStatsAndPrune() { | |||
| 	log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors) | ||||
| 
 | ||||
| 	// Delete expired attachments | ||||
| 	if s.fileCache != nil { | ||||
| 		ids, err := s.messageCache.AttachmentsExpired() | ||||
| 	if s.fileCache != nil && s.config.AttachmentExpiryDuration > 0 { | ||||
| 		olderThan := time.Now().Add(-1 * s.config.AttachmentExpiryDuration) | ||||
| 		ids, err := s.fileCache.Expired(olderThan) | ||||
| 		if err != nil { | ||||
| 			log.Warn("Error retrieving expired attachments: %s", err.Error()) | ||||
| 		} else if len(ids) > 0 { | ||||
|  |  | |||
|  | @ -26,6 +26,7 @@ | |||
| # This can be useful to avoid port issues on local systems, and to simplify permissions. | ||||
| # | ||||
| # listen-unix: <socket-path> | ||||
| # listen-unix-mode: <linux permissions, e.g. 0700> | ||||
| 
 | ||||
| # Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set. | ||||
| # | ||||
|  |  | |||
|  | @ -66,6 +66,8 @@ func TestServer_PublishWithFirebase(t *testing.T) { | |||
| 	msg1 := toMessage(t, response.Body.String()) | ||||
| 	require.NotEmpty(t, msg1.ID) | ||||
| 	require.Equal(t, "my first message", msg1.Message) | ||||
| 
 | ||||
| 	time.Sleep(100 * time.Millisecond) // Firebase publishing happens | ||||
| 	require.Equal(t, 1, len(sender.Messages())) | ||||
| 	require.Equal(t, "my first message", sender.Messages()[0].Data["message"]) | ||||
| 	require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body) | ||||
|  |  | |||
							
								
								
									
										56
									
								
								web/public/static/langs/zh_Hant.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								web/public/static/langs/zh_Hant.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,56 @@ | |||
| { | ||||
|     "action_bar_logo_alt": "ntfy 標識", | ||||
|     "action_bar_unsubscribe": "取消訂閱", | ||||
|     "action_bar_toggle_mute": "通知靜音/解除通知靜音", | ||||
|     "action_bar_toggle_action_menu": "開啟/關閉操作選單", | ||||
|     "message_bar_type_message": "在這邊輸入訊息", | ||||
|     "alert_grant_description": "允許瀏覽器權限以顯示桌面通知。", | ||||
|     "alert_grant_button": "允許", | ||||
|     "notifications_list": "通知清單", | ||||
|     "notifications_list_item": "通知", | ||||
|     "notifications_mark_read": "標示已讀", | ||||
|     "notifications_attachment_image": "附加圖片", | ||||
|     "notifications_attachment_copy_url_title": "複製附件URL到剪貼板", | ||||
|     "notifications_attachment_copy_url_button": "複製URL", | ||||
|     "notifications_attachment_open_title": "前往 {{url}}", | ||||
|     "notifications_attachment_open_button": "開啟附件", | ||||
|     "notifications_attachment_link_expired": "下載連結已過期", | ||||
|     "notifications_attachment_file_video": "影片檔案", | ||||
|     "notifications_attachment_file_app": "Android 應用程式檔案", | ||||
|     "notifications_attachment_file_document": "其他文件", | ||||
|     "notifications_click_copy_url_title": "複製連結URL到剪貼板", | ||||
|     "notifications_click_copy_url_button": "複製連結", | ||||
|     "notifications_click_open_button": "開啟連結", | ||||
|     "notifications_actions_not_supported": "網頁程式無法支援該動作", | ||||
|     "notifications_actions_http_request_title": "傳送 HTTP {{method}} 到 {{url}}", | ||||
|     "notifications_none_for_topic_title": "尚未收到任何此主題的通知。", | ||||
|     "notifications_none_for_topic_description": "如要寄送通知到此主題,請使用 PUT 或 POST 到此主題URL。", | ||||
|     "notifications_none_for_any_title": "尚未收到任何通知。", | ||||
|     "action_bar_settings": "設定", | ||||
|     "action_bar_send_test_notification": "寄送測試通知", | ||||
|     "action_bar_clear_notifications": "清除所有通知", | ||||
|     "action_bar_show_menu": "顯示選單", | ||||
|     "nav_button_documentation": "文件", | ||||
|     "nav_button_publish_message": "發布通知", | ||||
|     "nav_button_muted": "通知已靜音", | ||||
|     "notifications_copied_to_clipboard": "複製到剪貼板", | ||||
|     "message_bar_publish": "發布訊息", | ||||
|     "message_bar_show_dialog": "顯示發布對話筐", | ||||
|     "message_bar_error_publishing": "無法發布通知", | ||||
|     "nav_topics_title": "訂閱主題", | ||||
|     "nav_button_all_notifications": "所有通知", | ||||
|     "nav_button_settings": "設定", | ||||
|     "nav_button_subscribe": "訂閱主題", | ||||
|     "nav_button_connecting": "連線中", | ||||
|     "alert_grant_title": "通知已關閉", | ||||
|     "alert_not_supported_title": "不支援通知", | ||||
|     "alert_not_supported_description": "瀏覽器不支援通知。", | ||||
|     "notifications_tags": "標籤", | ||||
|     "notifications_priority_x": "優先度 {{priority}}", | ||||
|     "notifications_new_indicator": "新通知", | ||||
|     "notifications_attachment_file_audio": "聲音檔案", | ||||
|     "notifications_delete": "刪除", | ||||
|     "notifications_attachment_link_expires": "連結已過期 {{date}}", | ||||
|     "notifications_attachment_file_image": "圖片檔案", | ||||
|     "notifications_actions_open_url_title": "前往 {{url}}" | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue